diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a99bbca..0549d04b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/AGENTS.md b/AGENTS.md index 1b531835..58ea8385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/SECURITY.md b/SECURITY.md index e126d36a..0d6c120b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/tests/conftest.py b/conftest.py similarity index 100% rename from tests/conftest.py rename to conftest.py diff --git a/docs/README.md b/docs/README.md index bbec4734..e916f5cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 97c7afe5..8229b3cb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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//` | | 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 `/skills/` or built-in skills under `nanobot/skills/` | diff --git a/docs/channel-package-guide.md b/docs/channel-package-guide.md new file mode 100644 index 00000000..df6ce326 --- /dev/null +++ b/docs/channel-package-guide.md @@ -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//`; 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//` 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/.json` | +| Generic settings-shell copy shared by every channel | `webui/src/i18n/locales//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-specific runtime code, setup metadata, tests, WebUI structure, components, and translations stay under that directory. + +### Package Layout + +```text +nanobot/channels// +├── __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 + └── .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//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/.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..`, 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, "")`; 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//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//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 +nanobot channels login --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 `.`; +- 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 +``` diff --git a/docs/channel-plugin-guide.md b/docs/channel-plugin-guide.md deleted file mode 100644 index 4ce18187..00000000 --- a/docs/channel-plugin-guide.md +++ /dev/null @@ -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 -nanobot channels login --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 -``` diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 9ead67a0..c8c57d7c 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -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: diff --git a/nanobot/channels/__init__.py b/nanobot/channels/__init__.py index 588169db..1ef3cb96 100644 --- a/nanobot/channels/__init__.py +++ b/nanobot/channels/__init__.py @@ -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"] diff --git a/nanobot/channels/_manifest.py b/nanobot/channels/_manifest.py new file mode 100644 index 00000000..2d8f6ee2 --- /dev/null +++ b/nanobot/channels/_manifest.py @@ -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) diff --git a/nanobot/channels/_setup.py b/nanobot/channels/_setup.py index cb4a40fc..92a79ab6 100644 --- a/nanobot/channels/_setup.py +++ b/nanobot/channels/_setup.py @@ -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 diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index 04c71829..4dcbbf96 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -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.""" diff --git a/nanobot/channels/connect.py b/nanobot/channels/connect.py new file mode 100644 index 00000000..92d411e7 --- /dev/null +++ b/nanobot/channels/connect.py @@ -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"] diff --git a/nanobot/channels/contracts.py b/nanobot/channels/contracts.py new file mode 100644 index 00000000..560f17d4 --- /dev/null +++ b/nanobot/channels/contracts.py @@ -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) diff --git a/nanobot/channels/dingtalk/__init__.py b/nanobot/channels/dingtalk/__init__.py new file mode 100644 index 00000000..226e045e --- /dev/null +++ b/nanobot/channels/dingtalk/__init__.py @@ -0,0 +1 @@ +"""DingTalk channel package.""" diff --git a/nanobot/channels/dingtalk/manifest.py b/nanobot/channels/dingtalk/manifest.py new file mode 100644 index 00000000..767d5497 --- /dev/null +++ b/nanobot/channels/dingtalk/manifest.py @@ -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", +) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk/runtime.py similarity index 100% rename from nanobot/channels/dingtalk.py rename to nanobot/channels/dingtalk/runtime.py diff --git a/nanobot/channels/dingtalk/tests/__init__.py b/nanobot/channels/dingtalk/tests/__init__.py new file mode 100644 index 00000000..8b79cfa5 --- /dev/null +++ b/nanobot/channels/dingtalk/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the DingTalk channel package.""" diff --git a/tests/channels/test_dingtalk_channel.py b/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py similarity index 99% rename from tests/channels/test_dingtalk_channel.py rename to nanobot/channels/dingtalk/tests/test_dingtalk_channel.py index dcfcf610..884cf168 100644 --- a/tests/channels/test_dingtalk_channel.py +++ b/nanobot/channels/dingtalk/tests/test_dingtalk_channel.py @@ -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: diff --git a/nanobot/channels/dingtalk/tests/test_validation.py b/nanobot/channels/dingtalk/tests/test_validation.py new file mode 100644 index 00000000..f51d77b0 --- /dev/null +++ b/nanobot/channels/dingtalk/tests/test_validation.py @@ -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"]) diff --git a/nanobot/channels/dingtalk/webui/index.ts b/nanobot/channels/dingtalk/webui/index.ts new file mode 100644 index 00000000..ab352ee9 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/index.ts @@ -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; diff --git a/nanobot/channels/dingtalk/webui/locales/en.json b/nanobot/channels/dingtalk/webui/locales/en.json new file mode 100644 index 00000000..9dcfd66d --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/en.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/es.json b/nanobot/channels/dingtalk/webui/locales/es.json new file mode 100644 index 00000000..210747c7 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/es.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/fr.json b/nanobot/channels/dingtalk/webui/locales/fr.json new file mode 100644 index 00000000..d0eb8fa5 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/fr.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/id.json b/nanobot/channels/dingtalk/webui/locales/id.json new file mode 100644 index 00000000..c4728d00 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/id.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/ja.json b/nanobot/channels/dingtalk/webui/locales/ja.json new file mode 100644 index 00000000..3ffbd5d1 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/ja.json @@ -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(カンマ区切り)" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/ko.json b/nanobot/channels/dingtalk/webui/locales/ko.json new file mode 100644 index 00000000..d821ab3f --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/ko.json @@ -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, 쉼표로 구분" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/pt-BR.json b/nanobot/channels/dingtalk/webui/locales/pt-BR.json new file mode 100644 index 00000000..9dbca373 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/pt-BR.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/vi.json b/nanobot/channels/dingtalk/webui/locales/vi.json new file mode 100644 index 00000000..d0616d0a --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/vi.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/zh-CN.json b/nanobot/channels/dingtalk/webui/locales/zh-CN.json new file mode 100644 index 00000000..b80ad7b8 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/zh-CN.json @@ -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,用逗号分隔" + } + } + } +} diff --git a/nanobot/channels/dingtalk/webui/locales/zh-TW.json b/nanobot/channels/dingtalk/webui/locales/zh-TW.json new file mode 100644 index 00000000..2ce8a1c6 --- /dev/null +++ b/nanobot/channels/dingtalk/webui/locales/zh-TW.json @@ -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,以逗號分隔" + } + } + } +} diff --git a/nanobot/channels/discord/__init__.py b/nanobot/channels/discord/__init__.py new file mode 100644 index 00000000..30dcabcc --- /dev/null +++ b/nanobot/channels/discord/__init__.py @@ -0,0 +1 @@ +"""Discord channel package.""" diff --git a/nanobot/channels/discord/manifest.py b/nanobot/channels/discord/manifest.py new file mode 100644 index 00000000..8c6a1ab3 --- /dev/null +++ b/nanobot/channels/discord/manifest.py @@ -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", +) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord/runtime.py similarity index 100% rename from nanobot/channels/discord.py rename to nanobot/channels/discord/runtime.py diff --git a/nanobot/channels/discord/tests/__init__.py b/nanobot/channels/discord/tests/__init__.py new file mode 100644 index 00000000..83b90076 --- /dev/null +++ b/nanobot/channels/discord/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Discord channel package.""" diff --git a/tests/channels/test_discord_channel.py b/nanobot/channels/discord/tests/test_discord_channel.py similarity index 97% rename from tests/channels/test_discord_channel.py rename to nanobot/channels/discord/tests/test_discord_channel.py index 273a0790..d86b56b7 100644 --- a/tests/channels/test_discord_channel.py +++ b/nanobot/channels/discord/tests/test_discord_channel.py @@ -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() diff --git a/nanobot/channels/discord/validation.py b/nanobot/channels/discord/validation.py new file mode 100644 index 00000000..6b24e893 --- /dev/null +++ b/nanobot/channels/discord/validation.py @@ -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"] diff --git a/nanobot/channels/discord/webui/index.ts b/nanobot/channels/discord/webui/index.ts new file mode 100644 index 00000000..b6e58715 --- /dev/null +++ b/nanobot/channels/discord/webui/index.ts @@ -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; diff --git a/nanobot/channels/discord/webui/locales/en.json b/nanobot/channels/discord/webui/locales/en.json new file mode 100644 index 00000000..085a8bb7 --- /dev/null +++ b/nanobot/channels/discord/webui/locales/en.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/es.json b/nanobot/channels/discord/webui/locales/es.json new file mode 100644 index 00000000..cb52e3db --- /dev/null +++ b/nanobot/channels/discord/webui/locales/es.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/fr.json b/nanobot/channels/discord/webui/locales/fr.json new file mode 100644 index 00000000..6e7cbff4 --- /dev/null +++ b/nanobot/channels/discord/webui/locales/fr.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/id.json b/nanobot/channels/discord/webui/locales/id.json new file mode 100644 index 00000000..86a8780a --- /dev/null +++ b/nanobot/channels/discord/webui/locales/id.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/ja.json b/nanobot/channels/discord/webui/locales/ja.json new file mode 100644 index 00000000..08333c87 --- /dev/null +++ b/nanobot/channels/discord/webui/locales/ja.json @@ -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(カンマ区切り)" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/ko.json b/nanobot/channels/discord/webui/locales/ko.json new file mode 100644 index 00000000..c7d0d8a1 --- /dev/null +++ b/nanobot/channels/discord/webui/locales/ko.json @@ -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, 쉼표로 구분" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/pt-BR.json b/nanobot/channels/discord/webui/locales/pt-BR.json new file mode 100644 index 00000000..9db677ac --- /dev/null +++ b/nanobot/channels/discord/webui/locales/pt-BR.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/vi.json b/nanobot/channels/discord/webui/locales/vi.json new file mode 100644 index 00000000..f25fc357 --- /dev/null +++ b/nanobot/channels/discord/webui/locales/vi.json @@ -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" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/zh-CN.json b/nanobot/channels/discord/webui/locales/zh-CN.json new file mode 100644 index 00000000..78731647 --- /dev/null +++ b/nanobot/channels/discord/webui/locales/zh-CN.json @@ -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,用逗号分隔" + } + } + } +} diff --git a/nanobot/channels/discord/webui/locales/zh-TW.json b/nanobot/channels/discord/webui/locales/zh-TW.json new file mode 100644 index 00000000..83719e56 --- /dev/null +++ b/nanobot/channels/discord/webui/locales/zh-TW.json @@ -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,以逗號分隔" + } + } + } +} diff --git a/nanobot/channels/email/__init__.py b/nanobot/channels/email/__init__.py new file mode 100644 index 00000000..9c649f70 --- /dev/null +++ b/nanobot/channels/email/__init__.py @@ -0,0 +1 @@ +"""Email channel package.""" diff --git a/nanobot/channels/email/manifest.py b/nanobot/channels/email/manifest.py new file mode 100644 index 00000000..ba046d71 --- /dev/null +++ b/nanobot/channels/email/manifest.py @@ -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", +) diff --git a/nanobot/channels/email.py b/nanobot/channels/email/runtime.py similarity index 100% rename from nanobot/channels/email.py rename to nanobot/channels/email/runtime.py diff --git a/nanobot/channels/email/tests/__init__.py b/nanobot/channels/email/tests/__init__.py new file mode 100644 index 00000000..25fcea40 --- /dev/null +++ b/nanobot/channels/email/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the email channel package.""" diff --git a/tests/channels/test_email_channel.py b/nanobot/channels/email/tests/test_email_channel.py similarity index 92% rename from tests/channels/test_email_channel.py rename to nanobot/channels/email/tests/test_email_channel.py index a8c53c08..7e4a30a9 100644 --- a/tests/channels/test_email_channel.py +++ b/nanobot/channels/email/tests/test_email_channel.py @@ -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") diff --git a/nanobot/channels/email/tests/test_validation.py b/nanobot/channels/email/tests/test_validation.py new file mode 100644 index 00000000..914bb0d8 --- /dev/null +++ b/nanobot/channels/email/tests/test_validation.py @@ -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) diff --git a/nanobot/channels/email/validation.py b/nanobot/channels/email/validation.py new file mode 100644 index 00000000..ebcd410f --- /dev/null +++ b/nanobot/channels/email/validation.py @@ -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"] diff --git a/nanobot/channels/email/webui/index.ts b/nanobot/channels/email/webui/index.ts new file mode 100644 index 00000000..cea01cda --- /dev/null +++ b/nanobot/channels/email/webui/index.ts @@ -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; diff --git a/nanobot/channels/email/webui/locales/en.json b/nanobot/channels/email/webui/locales/en.json new file mode 100644 index 00000000..65c0dcd6 --- /dev/null +++ b/nanobot/channels/email/webui/locales/en.json @@ -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" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/es.json b/nanobot/channels/email/webui/locales/es.json new file mode 100644 index 00000000..1b443c0e --- /dev/null +++ b/nanobot/channels/email/webui/locales/es.json @@ -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" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/fr.json b/nanobot/channels/email/webui/locales/fr.json new file mode 100644 index 00000000..a5c192e6 --- /dev/null +++ b/nanobot/channels/email/webui/locales/fr.json @@ -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é" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/id.json b/nanobot/channels/email/webui/locales/id.json new file mode 100644 index 00000000..db5e4b1c --- /dev/null +++ b/nanobot/channels/email/webui/locales/id.json @@ -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" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/ja.json b/nanobot/channels/email/webui/locales/ja.json new file mode 100644 index 00000000..acf407f0 --- /dev/null +++ b/nanobot/channels/email/webui/locales/ja.json @@ -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": "オフ" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/ko.json b/nanobot/channels/email/webui/locales/ko.json new file mode 100644 index 00000000..c7b405c2 --- /dev/null +++ b/nanobot/channels/email/webui/locales/ko.json @@ -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": "꺼짐" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/pt-BR.json b/nanobot/channels/email/webui/locales/pt-BR.json new file mode 100644 index 00000000..b2d4bf3e --- /dev/null +++ b/nanobot/channels/email/webui/locales/pt-BR.json @@ -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" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/vi.json b/nanobot/channels/email/webui/locales/vi.json new file mode 100644 index 00000000..b5773a84 --- /dev/null +++ b/nanobot/channels/email/webui/locales/vi.json @@ -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" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/zh-CN.json b/nanobot/channels/email/webui/locales/zh-CN.json new file mode 100644 index 00000000..b2730b9e --- /dev/null +++ b/nanobot/channels/email/webui/locales/zh-CN.json @@ -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": "关闭" + } + } + } + } +} diff --git a/nanobot/channels/email/webui/locales/zh-TW.json b/nanobot/channels/email/webui/locales/zh-TW.json new file mode 100644 index 00000000..a7e08bc8 --- /dev/null +++ b/nanobot/channels/email/webui/locales/zh-TW.json @@ -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": "關閉" + } + } + } + } +} diff --git a/nanobot/channels/feishu/__init__.py b/nanobot/channels/feishu/__init__.py new file mode 100644 index 00000000..b4fb2e57 --- /dev/null +++ b/nanobot/channels/feishu/__init__.py @@ -0,0 +1 @@ +"""Feishu/Lark channel package.""" diff --git a/nanobot/channels/feishu/config.py b/nanobot/channels/feishu/config.py new file mode 100644 index 00000000..37e4aa3e --- /dev/null +++ b/nanobot/channels/feishu/config.py @@ -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"] diff --git a/nanobot/channels/feishu/connect.py b/nanobot/channels/feishu/connect.py new file mode 100644 index 00000000..12930e11 --- /dev/null +++ b/nanobot/channels/feishu/connect.py @@ -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.", + } diff --git a/nanobot/channels/_feishu_instances.py b/nanobot/channels/feishu/instances.py similarity index 54% rename from nanobot/channels/_feishu_instances.py rename to nanobot/channels/feishu/instances.py index e6a0a03a..44de6f9b 100644 --- a/nanobot/channels/_feishu_instances.py +++ b/nanobot/channels/feishu/instances.py @@ -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", +] diff --git a/nanobot/channels/feishu/manifest.py b/nanobot/channels/feishu/manifest.py new file mode 100644 index 00000000..ee6c00f4 --- /dev/null +++ b/nanobot/channels/feishu/manifest.py @@ -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", +) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu/runtime.py similarity index 96% rename from nanobot/channels/feishu.py rename to nanobot/channels/feishu/runtime.py index b90ea0d6..dcd8c9e9 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu/runtime.py @@ -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", diff --git a/nanobot/channels/feishu/tests/__init__.py b/nanobot/channels/feishu/tests/__init__.py new file mode 100644 index 00000000..23009afe --- /dev/null +++ b/nanobot/channels/feishu/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Feishu channel package.""" diff --git a/tests/channels/test_feishu_card_extraction.py b/nanobot/channels/feishu/tests/test_feishu_card_extraction.py similarity index 93% rename from tests/channels/test_feishu_card_extraction.py rename to nanobot/channels/feishu/tests/test_feishu_card_extraction.py index 05bfd7eb..ccbe8d32 100644 --- a/tests/channels/test_feishu_card_extraction.py +++ b/nanobot/channels/feishu/tests/test_feishu_card_extraction.py @@ -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: diff --git a/tests/channels/test_feishu_domain.py b/nanobot/channels/feishu/tests/test_feishu_domain.py similarity index 94% rename from tests/channels/test_feishu_domain.py rename to nanobot/channels/feishu/tests/test_feishu_domain.py index 87d78510..898a8d22 100644 --- a/tests/channels/test_feishu_domain.py +++ b/nanobot/channels/feishu/tests/test_feishu_domain.py @@ -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: diff --git a/nanobot/channels/feishu/tests/test_feishu_lazy_import.py b/nanobot/channels/feishu/tests/test_feishu_lazy_import.py new file mode 100644 index 00000000..7e28b35b --- /dev/null +++ b/nanobot/channels/feishu/tests/test_feishu_lazy_import.py @@ -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" diff --git a/tests/channels/test_feishu_login.py b/nanobot/channels/feishu/tests/test_feishu_login.py similarity index 89% rename from tests/channels/test_feishu_login.py rename to nanobot/channels/feishu/tests/test_feishu_login.py index 58f39993..3edb482b 100644 --- a/tests/channels/test_feishu_login.py +++ b/nanobot/channels/feishu/tests/test_feishu_login.py @@ -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, diff --git a/tests/channels/test_feishu_markdown_rendering.py b/nanobot/channels/feishu/tests/test_feishu_markdown_rendering.py similarity index 97% rename from tests/channels/test_feishu_markdown_rendering.py rename to nanobot/channels/feishu/tests/test_feishu_markdown_rendering.py index efcd2073..bf165a81 100644 --- a/tests/channels/test_feishu_markdown_rendering.py +++ b/nanobot/channels/feishu/tests/test_feishu_markdown_rendering.py @@ -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: diff --git a/tests/channels/test_feishu_media_filename_security.py b/nanobot/channels/feishu/tests/test_feishu_media_filename_security.py similarity index 90% rename from tests/channels/test_feishu_media_filename_security.py rename to nanobot/channels/feishu/tests/test_feishu_media_filename_security.py index 363bc99a..e2d72c75 100644 --- a/tests/channels/test_feishu_media_filename_security.py +++ b/nanobot/channels/feishu/tests/test_feishu_media_filename_security.py @@ -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 diff --git a/tests/channels/test_feishu_mention.py b/nanobot/channels/feishu/tests/test_feishu_mention.py similarity index 97% rename from tests/channels/test_feishu_mention.py rename to nanobot/channels/feishu/tests/test_feishu_mention.py index 660cdb5e..d2de1370 100644 --- a/tests/channels/test_feishu_mention.py +++ b/nanobot/channels/feishu/tests/test_feishu_mention.py @@ -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: diff --git a/tests/channels/test_feishu_mentions.py b/nanobot/channels/feishu/tests/test_feishu_mentions.py similarity index 97% rename from tests/channels/test_feishu_mentions.py rename to nanobot/channels/feishu/tests/test_feishu_mentions.py index 0404e87e..519b361c 100644 --- a/tests/channels/test_feishu_mentions.py +++ b/nanobot/channels/feishu/tests/test_feishu_mentions.py @@ -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 = ""): diff --git a/tests/channels/test_feishu_post_content.py b/nanobot/channels/feishu/tests/test_feishu_post_content.py similarity index 93% rename from tests/channels/test_feishu_post_content.py rename to nanobot/channels/feishu/tests/test_feishu_post_content.py index a4c5bae1..34e840a2 100644 --- a/tests/channels/test_feishu_post_content.py +++ b/nanobot/channels/feishu/tests/test_feishu_post_content.py @@ -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: diff --git a/tests/channels/test_feishu_reaction.py b/nanobot/channels/feishu/tests/test_feishu_reaction.py similarity index 99% rename from tests/channels/test_feishu_reaction.py rename to nanobot/channels/feishu/tests/test_feishu_reaction.py index 7721b4fe..2e1a8738 100644 --- a/tests/channels/test_feishu_reaction.py +++ b/nanobot/channels/feishu/tests/test_feishu_reaction.py @@ -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: diff --git a/tests/channels/test_feishu_reply.py b/nanobot/channels/feishu/tests/test_feishu_reply.py similarity index 99% rename from tests/channels/test_feishu_reply.py rename to nanobot/channels/feishu/tests/test_feishu_reply.py index c783827a..5afcafa3 100644 --- a/tests/channels/test_feishu_reply.py +++ b/nanobot/channels/feishu/tests/test_feishu_reply.py @@ -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 diff --git a/tests/channels/test_feishu_streaming.py b/nanobot/channels/feishu/tests/test_feishu_streaming.py similarity index 99% rename from tests/channels/test_feishu_streaming.py rename to nanobot/channels/feishu/tests/test_feishu_streaming.py index d683cfaa..7540d5a3 100644 --- a/tests/channels/test_feishu_streaming.py +++ b/nanobot/channels/feishu/tests/test_feishu_streaming.py @@ -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: diff --git a/tests/channels/test_feishu_table_split.py b/nanobot/channels/feishu/tests/test_feishu_table_split.py similarity index 98% rename from tests/channels/test_feishu_table_split.py rename to nanobot/channels/feishu/tests/test_feishu_table_split.py index 030b8910..081ccbf4 100644 --- a/tests/channels/test_feishu_table_split.py +++ b/nanobot/channels/feishu/tests/test_feishu_table_split.py @@ -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: diff --git a/tests/channels/test_feishu_tool_hint_code_block.py b/nanobot/channels/feishu/tests/test_feishu_tool_hint_code_block.py similarity index 99% rename from tests/channels/test_feishu_tool_hint_code_block.py rename to nanobot/channels/feishu/tests/test_feishu_tool_hint_code_block.py index 4fe31f52..63afc6c9 100644 --- a/tests/channels/test_feishu_tool_hint_code_block.py +++ b/nanobot/channels/feishu/tests/test_feishu_tool_hint_code_block.py @@ -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 diff --git a/nanobot/channels/feishu/tests/test_feishu_ws.py b/nanobot/channels/feishu/tests/test_feishu_ws.py new file mode 100644 index 00000000..c54c1433 --- /dev/null +++ b/nanobot/channels/feishu/tests/test_feishu_ws.py @@ -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 diff --git a/nanobot/channels/feishu/validation.py b/nanobot/channels/feishu/validation.py new file mode 100644 index 00000000..2091dbe1 --- /dev/null +++ b/nanobot/channels/feishu/validation.py @@ -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"] diff --git a/nanobot/channels/_feishu_ws.py b/nanobot/channels/feishu/websocket.py similarity index 58% rename from nanobot/channels/_feishu_ws.py rename to nanobot/channels/feishu/websocket.py index 8be894ba..d7d00560 100644 --- a/nanobot/channels/_feishu_ws.py +++ b/nanobot/channels/feishu/websocket.py @@ -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: diff --git a/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx b/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx new file mode 100644 index 00000000..f91a79fd --- /dev/null +++ b/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx @@ -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 ( + 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) => ( + + ), + footer: ( +
+
+ {tx("custom.createAnother", "Create another assistant")} +
+

+ {tx( + "custom.createHint", + "Create a separate Feishu bot for another team, space, or workflow.", + )} +

+ +
+ ), + }} + /> + ); +} + +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(null); + + if (!instance.configured) { + return ( + + ); + } + + 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 ( + <> +
+ +
+ {error ? ( +
+ {error} +
+ ) : 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)}`; +} diff --git a/nanobot/channels/feishu/webui/FeishuConnectFlow.tsx b/nanobot/channels/feishu/webui/FeishuConnectFlow.tsx new file mode 100644 index 00000000..7142ebe5 --- /dev/null +++ b/nanobot/channels/feishu/webui/FeishuConnectFlow.tsx @@ -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 ( + + ); +} diff --git a/nanobot/channels/feishu/webui/index.tsx b/nanobot/channels/feishu/webui/index.tsx new file mode 100644 index 00000000..c149e7b2 --- /dev/null +++ b/nanobot/channels/feishu/webui/index.tsx @@ -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; diff --git a/nanobot/channels/feishu/webui/locales/en.json b/nanobot/channels/feishu/webui/locales/en.json new file mode 100644 index 00000000..8075ea02 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/en.json @@ -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..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/es.json b/nanobot/channels/feishu/webui/locales/es.json new file mode 100644 index 00000000..618c7d59 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/es.json @@ -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..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/fr.json b/nanobot/channels/feishu/webui/locales/fr.json new file mode 100644 index 00000000..615d5f5e --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/fr.json @@ -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..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/id.json b/nanobot/channels/feishu/webui/locales/id.json new file mode 100644 index 00000000..f2104ee2 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/id.json @@ -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..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/ja.json b/nanobot/channels/feishu/webui/locales/ja.json new file mode 100644 index 00000000..07af1d5d --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/ja.json @@ -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": "接続中..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/ko.json b/nanobot/channels/feishu/webui/locales/ko.json new file mode 100644 index 00000000..c5f35a65 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/ko.json @@ -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": "연결 중..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/pt-BR.json b/nanobot/channels/feishu/webui/locales/pt-BR.json new file mode 100644 index 00000000..b6fa8f62 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/pt-BR.json @@ -0,0 +1,73 @@ +{ + "description": "Use o nanobot em conversas e grupos do Feishu.", + "requirements": "Credenciais do Feishu, assinatura de eventos e gateway", + "setup": { + "primaryAction": "Conectar Feishu", + "docsLabel": "Abrir guia do Feishu", + "officialLabel": "Abrir console do Feishu", + "tryIt": "Envie uma DM ou mencione o assistente em um grupo.", + "summary": "A conexão cria ou vincula um app Feishu por QR e salva as credenciais.", + "steps": [ + "Clique em Conectar e escaneie o QR com Feishu ou Lark.", + "Aprove a conexão. O nanobot salva App ID e Secret automaticamente.", + "Envie uma DM ao bot ou mencione-o em um grupo Feishu." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "cli_xxx" + }, + "appSecret": { + "label": "App Secret", + "placeholder": "Deixe vazio para manter o segredo", + "help": "Cole um novo apenas ao trocar credenciais." + }, + "domain": { + "label": "Região", + "choices": { + "feishu": "Feishu", + "lark": "Lark" + } + }, + "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" + }, + "topicIsolation": { + "label": "Isolamento por tópico", + "choices": { + "true": "Uma sessão separada por tópico", + "false": "Uma sessão compartilhada para o grupo" + } + } + } + }, + "custom": { + "toggleAssistant": "Assistente {{name}}", + "configured": "Conectado", + "needsSetup": "Precisa de autorização", + "noAppId": "Sem App ID", + "createAnother": "Criar outro assistente", + "createHint": "Crie um bot Feishu separado para outra equipe ou fluxo.", + "createAssistant": "Criar assistente", + "reconnect": "Reconectar", + "countNone": "Nenhum assistente conectado", + "countOne": "1 assistente conectado", + "countMany": "{{count}} assistentes conectados", + "qrAlt": "QR code de conexão do Feishu", + "scanTitle": "Escaneie com o Feishu", + "scanDescription": "Escaneie com Feishu ou Lark no celular. O nanobot concluirá a configuração após a autorização.", + "waiting": "Aguardando autorização...", + "connected": "Feishu está conectado.", + "stopped": "Conexão interrompida.", + "connecting": "Conectando..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/vi.json b/nanobot/channels/feishu/webui/locales/vi.json new file mode 100644 index 00000000..78d48cf5 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/vi.json @@ -0,0 +1,73 @@ +{ + "description": "Sử dụng nanobot trong cuộc trò chuyện và nhóm Feishu.", + "requirements": "Thông tin xác thực Feishu, đăng ký sự kiện và gateway", + "setup": { + "primaryAction": "Kết nối Feishu", + "docsLabel": "Mở hướng dẫn Feishu", + "officialLabel": "Mở bảng điều khiển Feishu", + "tryIt": "Gửi tin nhắn riêng hoặc nhắc trợ lý trong nhóm.", + "summary": "Kết nối tạo hoặc liên kết ứng dụng Feishu bằng QR và lưu thông tin xác thực.", + "steps": [ + "Nhấn Kết nối và quét QR bằng Feishu hoặc Lark.", + "Phê duyệt kết nối. nanobot tự lưu App ID và Secret.", + "Gửi tin nhắn riêng cho bot hoặc nhắc bot trong nhóm Feishu." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "cli_xxx" + }, + "appSecret": { + "label": "App Secret", + "placeholder": "Để trống để giữ secret hiện tại", + "help": "Chỉ dán secret mới khi xoay vòng thông tin xác thực." + }, + "domain": { + "label": "Khu vực", + "choices": { + "feishu": "Feishu", + "lark": "Lark" + } + }, + "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" + }, + "topicIsolation": { + "label": "Tách biệt chủ đề", + "choices": { + "true": "Phiên riêng cho từng chủ đề", + "false": "Dùng chung một phiên cho nhóm" + } + } + } + }, + "custom": { + "toggleAssistant": "Trợ lý {{name}}", + "configured": "Đã kết nối", + "needsSetup": "Cần cấp quyền", + "noAppId": "Không có App ID", + "createAnother": "Tạo trợ lý khác", + "createHint": "Tạo bot Feishu riêng cho nhóm hoặc quy trình khác.", + "createAssistant": "Tạo trợ lý", + "reconnect": "Kết nối lại", + "countNone": "Chưa kết nối trợ lý", + "countOne": "Đã kết nối 1 trợ lý", + "countMany": "Đã kết nối {{count}} trợ lý", + "qrAlt": "Mã QR kết nối Feishu", + "scanTitle": "Quét bằng Feishu", + "scanDescription": "Quét bằng Feishu hoặc Lark trên điện thoại. nanobot sẽ hoàn tất cấu hình sau khi cấp quyền.", + "waiting": "Đang chờ cấp quyền...", + "connected": "Feishu đã kết nối.", + "stopped": "Kết nối đã dừng.", + "connecting": "Đang kết nối..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/zh-CN.json b/nanobot/channels/feishu/webui/locales/zh-CN.json new file mode 100644 index 00000000..9f6bda39 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/zh-CN.json @@ -0,0 +1,74 @@ +{ + "displayName": "飞书", + "description": "在飞书会话和群组中使用 nanobot。", + "requirements": "飞书应用凭据、事件订阅和网关", + "setup": { + "primaryAction": "连接飞书", + "docsLabel": "打开飞书配置指南", + "officialLabel": "打开飞书开发者后台", + "tryIt": "向飞书助手发送私信,或在群组中提及它。", + "summary": "连接流程会通过二维码创建或关联飞书应用,并自动为 nanobot 保存应用凭据。", + "steps": [ + "点击连接,用手机飞书或 Lark 扫描二维码。", + "批准应用连接,nanobot 会自动保存 App ID 和 Secret。", + "向机器人发送私信,或在飞书群中提及它以完成测试。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "cli_xxx" + }, + "appSecret": { + "label": "App Secret", + "placeholder": "留空以保留现有密钥", + "help": "仅在轮换凭据时粘贴新的 App Secret。" + }, + "domain": { + "label": "区域", + "choices": { + "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": "为其他团队、空间或工作流创建独立的飞书机器人。", + "createAssistant": "创建助手", + "reconnect": "重新连接", + "countNone": "尚未连接助手", + "countOne": "已连接 1 个助手", + "countMany": "已连接 {{count}} 个助手", + "qrAlt": "飞书连接二维码", + "scanTitle": "使用飞书扫码", + "scanDescription": "用手机上的飞书或 Lark 扫描二维码。授权完成后,nanobot 会自动完成配置。", + "waiting": "正在等待授权...", + "connected": "飞书已连接。", + "stopped": "连接已停止。", + "connecting": "正在连接..." + } +} diff --git a/nanobot/channels/feishu/webui/locales/zh-TW.json b/nanobot/channels/feishu/webui/locales/zh-TW.json new file mode 100644 index 00000000..8b26eca9 --- /dev/null +++ b/nanobot/channels/feishu/webui/locales/zh-TW.json @@ -0,0 +1,74 @@ +{ + "displayName": "飛書", + "description": "在飛書對話和群組中使用 nanobot。", + "requirements": "飛書應用程式憑證、事件訂閱和閘道", + "setup": { + "primaryAction": "連接飛書", + "docsLabel": "開啟飛書設定指南", + "officialLabel": "開啟飛書開發者後台", + "tryIt": "向飛書助手傳送私訊,或在群組中提及它。", + "summary": "連接流程會透過二維碼建立或關聯飛書應用程式,並自動為 nanobot 儲存應用程式憑證。", + "steps": [ + "點擊連接,用手機飛書或 Lark 掃描二維碼。", + "批准應用程式連接,nanobot 會自動儲存 App ID 和 Secret。", + "向機器人傳送私訊,或在飛書群組中提及它以完成測試。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "cli_xxx" + }, + "appSecret": { + "label": "App Secret", + "placeholder": "留空以保留現有密鑰", + "help": "僅在輪換憑證時貼上新的 App Secret。" + }, + "domain": { + "label": "區域", + "choices": { + "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": "為其他團隊、空間或工作流程建立獨立的飛書機器人。", + "createAssistant": "建立助手", + "reconnect": "重新連線", + "countNone": "尚未連接助手", + "countOne": "已連接 1 個助手", + "countMany": "已連接 {{count}} 個助手", + "qrAlt": "飛書連線 QR Code", + "scanTitle": "使用飛書掃描", + "scanDescription": "請使用手機上的飛書或 Lark 掃描此 QR Code。完成授權後,nanobot 會自動完成設定。", + "waiting": "正在等待授權…", + "connected": "飛書已連線。", + "stopped": "連線已停止。", + "connecting": "正在連線…" + } +} diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index eb1bb046..03fe89be 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -4,8 +4,7 @@ from __future__ import annotations import asyncio import hashlib -import inspect -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Any @@ -24,9 +23,15 @@ from nanobot.bus.outbound_events import ( replace_outbound_event, ) from nanobot.bus.queue import MessageBus -from nanobot.channels._feishu_instances import ChannelInstanceSpec, feishu_instance_specs +from nanobot.channels._setup import channel_setup_spec from nanobot.channels.base import BaseChannel -from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS +from nanobot.channels.contracts import ( + channel_default_config, + channel_instance_specs, + channel_runtime_name, + resolve_channel_action_target, +) +from nanobot.channels.registry import channel_default_enabled from nanobot.config.schema import Config from nanobot.utils.restart import ( RestartNotice, @@ -60,22 +65,12 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = { } def _default_channel_config(name: str) -> dict[str, Any] | None: - if name != "websocket": + from nanobot.channels.registry import load_channel_plugin + + plugin = load_channel_plugin(name) + if not plugin.default_enabled: return None - from nanobot.channels.websocket import WebSocketChannel - - return WebSocketChannel.default_config() - - -def _channel_config_enabled(name: str, section: Any) -> bool: - if name == "feishu": - from nanobot.channels.feishu import FeishuChannel - - return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True)) - default_enabled = name in DEFAULT_ENABLED_CHANNELS - if isinstance(section, dict): - return bool(section.get("enabled", default_enabled)) - return bool(getattr(section, "enabled", default_enabled)) + return channel_default_config(plugin) class ChannelManager: @@ -115,6 +110,9 @@ class ChannelManager: self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) self.channels: dict[str, BaseChannel] = {} + self._channel_owners: dict[str, str] = {} + self._channel_runtime_specs: dict[str, tuple[str, str]] = {} + self._channel_errors: dict[str, str] = {} self._channel_tasks: dict[str, asyncio.Task] = {} self._dispatch_task: asyncio.Task | None = None self._started = False @@ -122,20 +120,19 @@ class ChannelManager: self._init_channels() - def _config_extra_channel_names(self, config: Config | None = None) -> set[str]: - extra = getattr((config or self.config).channels, "__pydantic_extra__", None) or {} - return set(extra.keys()) - def _channel_section( self, name: str, *, config: Config | None = None, default_sections: dict[str, Any] | None = None, + default_enabled: bool | None = None, ) -> Any: config = config or self.config section = getattr(config.channels, name, None) - if section is not None or name not in DEFAULT_ENABLED_CHANNELS: + if default_enabled is None: + default_enabled = channel_default_enabled(name) + if section is not None or not default_enabled: return section if default_sections is None: return _default_channel_config(name) @@ -145,29 +142,6 @@ class ChannelManager: default_sections[name] = default return default_sections.get(name) - def _channel_instance_specs( - self, - name: str, - cls: type[BaseChannel], - section: Any, - *, - enabled_only: bool = True, - ) -> list[ChannelInstanceSpec]: - if name == "feishu": - return feishu_instance_specs( - section, - cls.default_config(), - enabled_only=enabled_only, - ) - return [ - ChannelInstanceSpec( - base_name=name, - instance_id="default", - runtime_name=name, - config=section, - ) - ] - def _build_channel( self, name: str, @@ -178,7 +152,7 @@ class ChannelManager: ) -> BaseChannel: kwargs: dict[str, Any] = {} if cls.name == "websocket": - from nanobot.channels.websocket import WebSocketConfig + from nanobot.channels.websocket.runtime import WebSocketConfig from nanobot.webui.gateway_services import build_gateway_services parsed = WebSocketConfig.model_validate(section) @@ -200,6 +174,7 @@ class ChannelManager: cron_pending_job_ids=self._webui_cron_pending_job_ids, local_trigger_pending_ids=self._webui_local_trigger_pending_ids, channel_feature_action=self.apply_channel_feature_action, + channel_runtime_status=self.get_status, logger=logger, ) kwargs["gateway"] = gateway @@ -218,47 +193,99 @@ class ChannelManager: return channel def _init_channels(self) -> None: - """Initialize channels discovered via pkgutil scan + entry_points plugins.""" - from nanobot.channels.registry import discover_channel_names, discover_enabled + """Initialize enabled runtimes from dependency-free channel descriptors.""" + from nanobot.channels.registry import discover_plugins + from nanobot.optional_features import ensure_enabled_channel_dependencies - # Collect enabled module names first, then only import those. - # Channel configs live in ChannelsConfig's extra fields (via - # extra="allow"), so we enumerate candidates from pkgutil scan - # (cheap, no imports) and any plugin keys in __pydantic_extra__. - names = discover_channel_names() - candidate_names = set(names) | self._config_extra_channel_names() + plugins = discover_plugins() default_sections: dict[str, Any] = {} - + activations: dict[str, tuple[Any, list[tuple[str, Any]]]] = {} enabled_names: set[str] = set() - for name in candidate_names: - section = self._channel_section(name, default_sections=default_sections) - if section is None: - continue - if _channel_config_enabled(name, section): - enabled_names.add(name) - - for name, cls in discover_enabled( - enabled_names, - _names=names, - warn_import_errors=True, - ).items(): - section = self._channel_section(name, default_sections=default_sections) + for name, plugin in plugins.items(): + section = self._channel_section( + name, + default_sections=default_sections, + default_enabled=plugin.default_enabled, + ) if section is None: continue try: - for spec in self._channel_instance_specs(name, cls, section): - self.channels[spec.runtime_name] = self._build_channel( - name, - cls, - spec.config, - runtime_name=spec.runtime_name, + channel_setup_spec(name, plugin=plugin) + specs = channel_instance_specs(plugin, section) + runtime_specs = [ + (channel_runtime_name(plugin, spec.instance_id), spec) + for spec in specs + ] + except Exception as exc: + logger.warning("Could not inspect {} channel activation: {}", name, exc) + continue + if not runtime_specs: + continue + collisions = sorted( + set(self._channel_runtime_specs) + & {runtime_name for runtime_name, _spec in runtime_specs} + ) + if collisions: + logger.warning( + "{} channel runtime name(s) are already claimed: {}", + name, + ", ".join(collisions), + ) + continue + for runtime_name, spec in runtime_specs: + self._channel_runtime_specs[runtime_name] = (name, spec.instance_id) + activations[name] = (plugin, runtime_specs) + enabled_names.add(name) + + dependency_errors = ensure_enabled_channel_dependencies(enabled_names, plugins) + for name, error in dependency_errors.items(): + self._mark_channel_error(name, error) + + for name, (plugin, runtime_specs) in activations.items(): + if name in dependency_errors: + continue + try: + cls = plugin.load_channel_class() + built = [ + ( + runtime_name, + self._build_channel( + name, + cls, + spec.config, + runtime_name=runtime_name, + ), ) - logger.info("{} channel enabled as {}", cls.display_name, spec.runtime_name) - except Exception as e: - logger.warning("{} channel not available: {}", name, e) + for runtime_name, spec in runtime_specs + ] + for runtime_name, channel in built: + self.channels[runtime_name] = channel + self._channel_owners[runtime_name] = name + logger.info("{} channel enabled as {}", cls.display_name, runtime_name) + except Exception as exc: + self._mark_channel_error( + name, + "Channel runtime could not be loaded. Check gateway logs.", + ) + logger.warning("{} channel not available: {}", name, exc) self._validate_allow_from() + def _mark_channel_error(self, owner: str, message: str) -> None: + self._mark_runtime_error( + ( + runtime_name + for runtime_name, (runtime_owner, _instance_id) + in self._channel_runtime_specs.items() + if runtime_owner == owner + ), + message, + ) + + def _mark_runtime_error(self, runtime_names: Iterable[str], message: str) -> None: + for runtime_name in runtime_names: + self._channel_errors[runtime_name] = message + def _validate_allow_from(self) -> None: for name, ch in self.channels.items(): cfg = ch.config @@ -304,9 +331,16 @@ class ChannelManager: async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" + errors = getattr(self, "_channel_errors", None) + if errors is None: + errors = self._channel_errors = {} + errors.pop(name, None) try: await channel.start() + except asyncio.CancelledError: + raise except Exception: + errors[name] = "Channel failed to start. Check gateway logs." logger.exception("Failed to start channel {}", name) def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task: @@ -338,18 +372,12 @@ class ChannelManager: await task return True - def _is_known_channel_name(self, name: str) -> bool: - from nanobot.channels.registry import discover_channel_names, discover_plugins - - return name in set(discover_channel_names()) or name in discover_plugins() - - def _load_channel_class(self, name: str) -> type[BaseChannel] | None: - from nanobot.channels.registry import discover_channel_names, discover_enabled - - names = discover_channel_names() - return discover_enabled({name}, _names=names, warn_import_errors=True).get(name) - - async def apply_channel_feature_action(self, action: str, name: str) -> dict[str, Any]: + async def apply_channel_feature_action( + self, + action: str, + name: str, + instance_id: str | None = None, + ) -> dict[str, Any]: """Apply a WebUI channel enable/disable action without restarting the gateway. Returns a small transport-neutral result. ``handled=False`` means the @@ -357,35 +385,44 @@ class ChannelManager: response semantics. """ name = name.strip() - instance_id = "" - if "." in name: - name, instance_id = name.split(".", 1) - if not name or not self._is_known_channel_name(name): + instance_id = (instance_id or "").strip() or None + if not name: return {"handled": False} - if name == "websocket": + + from nanobot.channels.registry import discover_plugins + + plugin = discover_plugins({name}).get(name) + if plugin is None: + return {"handled": False} + if "always_enabled" in plugin.capabilities: return { "handled": True, "ok": False, "requires_restart": True, - "message": "WebSocket hosts the WebUI and is applied on restart.", + "message": f"{plugin.display_name} is always enabled and is applied on restart.", } from nanobot.config.loader import load_config self.config = load_config() - section = self._channel_section(name) + section = self._channel_section(name, default_enabled=plugin.default_enabled) + channel_setup_spec(name, plugin=plugin) + instance_id = resolve_channel_action_target(instance_id) + if action == "disable": - runtime_names = [name if not instance_id else f"{name}.{instance_id}"] - if name == "feishu" and not instance_id: - runtime_names = [ - runtime_name - for runtime_name in self.channels - if runtime_name == "feishu" or runtime_name.startswith("feishu.") - ] + runtime_name = channel_runtime_name(plugin, instance_id) + runtime_names = ( + [runtime_name] + if self._channel_owners.get(runtime_name) == name + else [] + ) stopped = False for runtime_name in runtime_names: stopped = await self._stop_channel(runtime_name) or stopped self.channels.pop(runtime_name, None) + self._channel_owners.pop(runtime_name, None) + self._channel_runtime_specs.pop(runtime_name, None) + self._channel_errors.pop(runtime_name, None) return { "handled": True, "ok": True, @@ -396,26 +433,8 @@ class ChannelManager: if action != "enable": return {"handled": True, "ok": False, "requires_restart": True} - if section is None or not _channel_config_enabled(name, section): - return { - "handled": True, - "ok": False, - "requires_restart": True, - "message": f"{name} channel config was not enabled.", - } - - cls = self._load_channel_class(name) - if cls is None: - return { - "handled": True, - "ok": False, - "requires_restart": True, - "message": f"{name} channel could not be loaded.", - } - - specs = self._channel_instance_specs(name, cls, section) - if instance_id: - specs = [spec for spec in specs if spec.instance_id == instance_id] + specs = channel_instance_specs(plugin, section) if section is not None else [] + specs = [spec for spec in specs if spec.instance_id == instance_id] if not specs: return { "handled": True, @@ -424,42 +443,102 @@ class ChannelManager: "message": f"{name} channel config was not enabled.", } - try: - built = [ - ( - spec.runtime_name, - self._build_channel( - name, - cls, - spec.config, - runtime_name=spec.runtime_name, - ), - ) - for spec in specs - ] - except Exception as exc: - logger.exception("Failed to build {} channel after settings change", name) + runtime_specs = [ + (channel_runtime_name(plugin, spec.instance_id), spec) + for spec in specs + ] + collisions = [ + runtime_name + for runtime_name, _spec in runtime_specs + if ( + runtime_name in self.channels + and self._channel_owners.get(runtime_name) != name + ) + ] + if collisions: return { "handled": True, "ok": False, "requires_restart": True, - "message": f"{name} channel could not be started: {exc}", + "message": ( + "Channel runtime name(s) already owned by another channel: " + + ", ".join(sorted(collisions)) + ), + } + for runtime_name, spec in runtime_specs: + self._channel_runtime_specs[runtime_name] = (name, spec.instance_id) + + try: + cls = plugin.load_channel_class() + except Exception: + self._mark_runtime_error( + (runtime_name for runtime_name, _spec in runtime_specs), + "Channel runtime could not be loaded. Check gateway logs.", + ) + return { + "handled": True, + "ok": False, + "requires_restart": False, + "message": f"{name} channel could not be loaded. Check gateway logs.", } - for runtime_name, _channel in built: - if runtime_name in self.channels: - await self._stop_channel(runtime_name) + try: + built = [ + ( + runtime_name, + self._build_channel( + name, + cls, + spec.config, + runtime_name=runtime_name, + ), + ) + for runtime_name, spec in runtime_specs + ] + except Exception: + self._mark_runtime_error( + (runtime_name for runtime_name, _spec in runtime_specs), + "Channel runtime could not be built. Check gateway logs.", + ) + logger.exception("Failed to build {} channel after settings change", name) + return { + "handled": True, + "ok": False, + "requires_restart": False, + "message": f"{name} channel could not be started. Check gateway logs.", + } + + runtime_names_to_replace = {runtime_name for runtime_name, _channel in built} + for runtime_name in sorted(runtime_names_to_replace): + if runtime_name not in self.channels: + continue + await self._stop_channel(runtime_name) + self.channels.pop(runtime_name, None) + self._channel_owners.pop(runtime_name, None) for runtime_name, channel in built: self.channels[runtime_name] = channel + self._channel_owners[runtime_name] = name + self._channel_errors.pop(runtime_name, None) if self._started: self._start_channel_task(runtime_name, channel) logger.info("{} channel applied without restart", runtime_name) + if self._started: + await asyncio.sleep(0) + failed = [ + runtime_name + for runtime_name, _channel in built + if runtime_name in self._channel_errors + ] return { "handled": True, - "ok": True, + "ok": not failed, "requires_restart": False, - "message": f"{cls.display_name} channel applied without restart.", + "message": ( + f"{cls.display_name} channel failed to start. Check gateway logs." + if failed + else f"{cls.display_name} channel applied without restart." + ), } async def start_all(self) -> None: @@ -654,84 +733,43 @@ class ChannelManager: break @staticmethod - def _accepts_keyword(callable_obj: Callable[..., Any], name: str) -> bool: - try: - signature = inspect.signature(callable_obj) - except (TypeError, ValueError): - return True - return any( - parameter.kind is inspect.Parameter.VAR_KEYWORD or parameter.name == name - for parameter in signature.parameters.values() - ) - - @classmethod - async def _send_reasoning_delta(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None: - metadata = msg.metadata - kwargs: dict[str, Any] = {} - if cls._accepts_keyword(channel.send_reasoning_delta, "stream_id"): - kwargs["stream_id"] = event.stream_id - else: - metadata = dict(metadata or {}) - metadata["_reasoning_delta"] = True - if event.stream_id is not None: - metadata["_stream_id"] = event.stream_id + async def _send_reasoning_delta( + channel: BaseChannel, + msg: OutboundMessage, + event: ProgressEvent, + ) -> None: await channel.send_reasoning_delta( msg.chat_id, msg.content, - metadata, - **kwargs, + msg.metadata, + stream_id=event.stream_id, ) - @classmethod - async def _send_reasoning_end(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None: - metadata = msg.metadata - kwargs: dict[str, Any] = {} - if cls._accepts_keyword(channel.send_reasoning_end, "stream_id"): - kwargs["stream_id"] = event.stream_id - else: - metadata = dict(metadata or {}) - metadata["_reasoning_end"] = True - if event.stream_id is not None: - metadata["_stream_id"] = event.stream_id + @staticmethod + async def _send_reasoning_end( + channel: BaseChannel, + msg: OutboundMessage, + event: ProgressEvent, + ) -> None: await channel.send_reasoning_end( msg.chat_id, - metadata, - **kwargs, + msg.metadata, + stream_id=event.stream_id, ) - @classmethod + @staticmethod async def _send_stream_event( - cls, channel: BaseChannel, msg: OutboundMessage, event: StreamDeltaEvent | StreamEndEvent, ) -> None: - metadata = msg.metadata - kwargs: dict[str, Any] = {} - if cls._accepts_keyword(channel.send_delta, "stream_id"): - kwargs["stream_id"] = event.stream_id - else: - metadata = dict(metadata or {}) - if event.stream_id is not None: - metadata["_stream_id"] = event.stream_id - - if isinstance(event, StreamEndEvent): - if cls._accepts_keyword(channel.send_delta, "stream_end"): - kwargs["stream_end"] = True - else: - metadata = dict(metadata or {}) - metadata["_stream_end"] = True - if cls._accepts_keyword(channel.send_delta, "resuming"): - kwargs["resuming"] = event.resuming - elif not kwargs: - metadata = dict(metadata or {}) - metadata["_stream_delta"] = True - await channel.send_delta( msg.chat_id, msg.content, - metadata, - **kwargs, + msg.metadata, + stream_id=event.stream_id, + stream_end=isinstance(event, StreamEndEvent), + resuming=event.resuming if isinstance(event, StreamEndEvent) else False, ) @staticmethod @@ -880,14 +918,40 @@ class ChannelManager: return self.channels.get(name) def get_status(self) -> dict[str, Any]: - """Get status of all channels.""" - return { - name: { + """Return actual runtime state, including enabled runtimes that failed.""" + owners = getattr(self, "_channel_owners", {}) + runtime_specs = dict(getattr(self, "_channel_runtime_specs", {})) + for runtime_name in self.channels: + runtime_specs.setdefault( + runtime_name, + (owners.get(runtime_name, runtime_name), "default"), + ) + tasks = getattr(self, "_channel_tasks", {}) + errors = getattr(self, "_channel_errors", {}) + status: dict[str, Any] = {} + for runtime_name, (owner, instance_id) in runtime_specs.items(): + channel = self.channels.get(runtime_name) + task = tasks.get(runtime_name) + error = errors.get(runtime_name) + running = bool(channel and channel.is_running) + if error: + state = "failed" + elif running: + state = "running" + elif task is not None and not task.done(): + state = "starting" + else: + state = "stopped" + status[runtime_name] = { "enabled": True, - "running": channel.is_running + "running": running, + "state": state, + "owner": owner, + "instance_id": instance_id, } - for name, channel in self.channels.items() - } + if error: + status[runtime_name]["error"] = error + return status @property def enabled_channels(self) -> list[str]: diff --git a/nanobot/channels/matrix/__init__.py b/nanobot/channels/matrix/__init__.py new file mode 100644 index 00000000..e118cd33 --- /dev/null +++ b/nanobot/channels/matrix/__init__.py @@ -0,0 +1 @@ +"""Matrix channel package.""" diff --git a/nanobot/channels/matrix/manifest.py b/nanobot/channels/matrix/manifest.py new file mode 100644 index 00000000..d669b216 --- /dev/null +++ b/nanobot/channels/matrix/manifest.py @@ -0,0 +1,39 @@ +"""Matrix management contract.""" + +from nanobot.channels._manifest import GROUP_POLICIES, field, one_of, required_fields +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.matrix.validation import validate +from nanobot.channels.plugin import ChannelPlugin + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "homeserver": field(default="https://matrix.org"), + "userId": field(), + "password": field("secret"), + "accessToken": field("secret"), + "deviceId": field(), + "groupPolicy": field("enum", choices=GROUP_POLICIES, default="open"), + "allowFrom": field("list", writable=False), + }, + required=( + *required_fields("homeserver", "userId"), + one_of(("password",), ("accessToken", "deviceId")), + ), + official_url="https://matrix.org/ecosystem/clients/", + validator=validate, +) + +PLUGIN = ChannelPlugin( + name="matrix", + display_name="Matrix", + runtime=f"{__package__}.runtime:MatrixChannel", + setup=SETUP_SPEC, + dependencies=( + "matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'", + "matrix-nio>=0.25.2; sys_platform == 'win32'", + "aiohttp>=3.9.0,<4.0.0", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", + ), + webui="webui/index.ts", +) diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix/runtime.py similarity index 99% rename from nanobot/channels/matrix.py rename to nanobot/channels/matrix/runtime.py index 6f007497..2544d64e 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix/runtime.py @@ -894,7 +894,7 @@ class MatrixChannel(BaseChannel): def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None: info = self._event_source_content(event).get("info") size = info.get("size") if isinstance(info, dict) else None - return size if type(size) is int and size >= 0 else None + return size if type(size) is int and size >= 0 else None # noqa: E721 def _event_mime(self, event: MatrixMediaEvent) -> str | None: info = self._event_source_content(event).get("info") diff --git a/nanobot/channels/matrix/tests/__init__.py b/nanobot/channels/matrix/tests/__init__.py new file mode 100644 index 00000000..cbb0c8b5 --- /dev/null +++ b/nanobot/channels/matrix/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Matrix channel package.""" diff --git a/tests/channels/test_matrix_channel.py b/nanobot/channels/matrix/tests/test_matrix_channel.py similarity index 97% rename from tests/channels/test_matrix_channel.py rename to nanobot/channels/matrix/tests/test_matrix_channel.py index 66750904..58ed088b 100644 --- a/tests/channels/test_matrix_channel.py +++ b/nanobot/channels/matrix/tests/test_matrix_channel.py @@ -1,3 +1,5 @@ +# ruff: noqa: E402 + import asyncio import sys from pathlib import Path @@ -10,11 +12,11 @@ pytest.importorskip("nh3") pytest.importorskip("mistune") from nio import RoomSendResponse, SyncError -import nanobot.channels.matrix as matrix_module +import nanobot.channels.matrix.runtime as matrix_module from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus -from nanobot.channels.matrix import ( +from nanobot.channels.matrix.runtime import ( MATRIX_HTML_FORMAT, TYPING_NOTICE_TIMEOUT_MS, MatrixChannel, @@ -288,14 +290,14 @@ async def test_start_skips_load_store_when_device_id_missing( coro.close() return _DummyTask() - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) monkeypatch.setattr( - "nanobot.channels.matrix.AsyncClientConfig", + "nanobot.channels.matrix.runtime.AsyncClientConfig", lambda **kwargs: SimpleNamespace(**kwargs), ) - monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client) + monkeypatch.setattr("nanobot.channels.matrix.runtime.AsyncClient", _fake_client) monkeypatch.setattr( - "nanobot.channels.matrix.asyncio.create_task", _fake_create_task + "nanobot.channels.matrix.runtime.asyncio.create_task", _fake_create_task ) channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=True), MessageBus()) @@ -470,14 +472,14 @@ async def test_start_disables_e2ee_when_configured( coro.close() return _DummyTask() - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) monkeypatch.setattr( - "nanobot.channels.matrix.AsyncClientConfig", + "nanobot.channels.matrix.runtime.AsyncClientConfig", lambda **kwargs: SimpleNamespace(**kwargs), ) - monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client) + monkeypatch.setattr("nanobot.channels.matrix.runtime.AsyncClient", _fake_client) monkeypatch.setattr( - "nanobot.channels.matrix.asyncio.create_task", _fake_create_task + "nanobot.channels.matrix.runtime.asyncio.create_task", _fake_create_task ) channel = MatrixChannel(_make_config(device_id="", e2ee_enabled=False), MessageBus()) @@ -909,7 +911,7 @@ async def test_on_message_sets_thread_metadata_when_threaded_event() -> None: async def test_on_media_message_downloads_attachment_and_sets_metadata( monkeypatch, tmp_path ) -> None: - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) @@ -969,7 +971,7 @@ async def test_on_media_message_downloads_attachment_and_sets_metadata( async def test_on_media_message_sets_thread_metadata_when_threaded_event( monkeypatch, tmp_path ) -> None: - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) @@ -1014,7 +1016,7 @@ async def test_on_media_message_sets_thread_metadata_when_threaded_event( async def test_on_media_message_respects_declared_size_limit( monkeypatch, tmp_path ) -> None: - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(max_media_bytes=3), MessageBus()) client = _FakeAsyncClient("", "", "", None) @@ -1049,7 +1051,7 @@ async def test_on_media_message_respects_declared_size_limit( async def test_on_media_message_uses_server_limit_when_smaller_than_local_limit( monkeypatch, tmp_path ) -> None: - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus()) client = _FakeAsyncClient("", "", "", None) @@ -1083,7 +1085,7 @@ async def test_on_media_message_uses_server_limit_when_smaller_than_local_limit( @pytest.mark.asyncio async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) channel = MatrixChannel(_make_config(), MessageBus()) client = _FakeAsyncClient("", "", "", None) @@ -1122,7 +1124,7 @@ async def test_on_media_message_handles_download_error(monkeypatch, tmp_path) -> @pytest.mark.asyncio async def test_on_media_message_decrypts_encrypted_media(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) monkeypatch.setattr( matrix_module, "decrypt_attachment", @@ -1172,7 +1174,7 @@ async def test_on_media_message_decrypts_encrypted_media(monkeypatch, tmp_path) @pytest.mark.asyncio async def test_on_media_message_handles_decrypt_error(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_data_dir", lambda: tmp_path) def _raise(*args, **kwargs): raise matrix_module.EncryptionError("boom") @@ -2081,7 +2083,7 @@ async def test_fetch_media_rejects_missing_declared_size(monkeypatch, tmp_path) channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client - monkeypatch.setattr("nanobot.channels.matrix.get_media_dir", lambda _name: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_should_not_run(*_args, **_kwargs): raise AssertionError("download should be rejected before fetching bytes") @@ -2109,7 +2111,7 @@ async def test_fetch_media_rejects_bool_declared_size(monkeypatch, tmp_path) -> channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client - monkeypatch.setattr("nanobot.channels.matrix.get_media_dir", lambda _name: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_should_not_run(*_args, **_kwargs): raise AssertionError("bool size should be rejected before fetching bytes") @@ -2137,7 +2139,7 @@ async def test_fetch_media_rejects_declared_oversized_before_download(monkeypatc channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client - monkeypatch.setattr("nanobot.channels.matrix.get_media_dir", lambda _name: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_should_not_run(*_args, **_kwargs): raise AssertionError("download should be rejected before fetching bytes") @@ -2165,7 +2167,7 @@ async def test_fetch_media_maps_streaming_cap_to_too_large(monkeypatch, tmp_path channel = MatrixChannel(_make_config(max_media_bytes=8), MessageBus()) client = _FakeAsyncClient("https://matrix.org", "", "", None) channel.client = client - monkeypatch.setattr("nanobot.channels.matrix.get_media_dir", lambda _name: tmp_path) + monkeypatch.setattr("nanobot.channels.matrix.runtime.get_media_dir", lambda _name: tmp_path) async def _download_too_large(_mxc_url: str, _limit_bytes: int): raise matrix_module._MediaTooLargeError diff --git a/nanobot/channels/matrix/tests/test_validation.py b/nanobot/channels/matrix/tests/test_validation.py new file mode 100644 index 00000000..6cd621a7 --- /dev/null +++ b/nanobot/channels/matrix/tests/test_validation.py @@ -0,0 +1,51 @@ +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 + + +@pytest.mark.parametrize( + ("credentials", "expected_status", "expected_missing"), + [ + ({}, "needs_setup", "password_or_accessToken"), + ({"channels.matrix.accessToken": "token"}, "needs_setup", "deviceId"), + ({"channels.matrix.password": "secret"}, "configured", None), + ( + { + "channels.matrix.accessToken": "token", + "channels.matrix.deviceId": "DEVICE", + }, + "configured", + None, + ), + ], +) +def test_validate_matrix_requires_a_complete_login_method( + tmp_path, + monkeypatch: pytest.MonkeyPatch, + credentials: dict[str, str], + expected_status: str, + expected_missing: str | None, +) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + result = validate_channel_config( + "matrix", + { + "channels.matrix.homeserver": "https://matrix.example", + "channels.matrix.userId": "@nanobot:matrix.example", + **credentials, + }, + ) + + assert result["status"] == expected_status + assert result["can_enable"] is (expected_status == "configured") + if expected_missing is None: + assert result["missing_fields"] == [] + else: + assert expected_missing in result["missing_fields"] diff --git a/nanobot/channels/matrix/validation.py b/nanobot/channels/matrix/validation.py new file mode 100644 index 00000000..19b9a8c8 --- /dev/null +++ b/nanobot/channels/matrix/validation.py @@ -0,0 +1,46 @@ +"""Matrix setup validation owned by the channel package.""" + +from typing import Any + +from nanobot.channels.contracts import ChannelValidationContext +from nanobot.channels.validation import check, required_checks, status_from_checks, string_value + + +def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: + checks, missing = required_checks("matrix", values) + password = string_value(values.get("password")) + access_token = string_value(values.get("accessToken")) + device_id = string_value(values.get("deviceId")) + + if password: + checks.append(check("login", "Login credentials", "pass", "Password login is configured.")) + elif access_token and device_id: + checks.append( + check( + "login", + "Login credentials", + "pass", + "Access token login is configured with its device ID.", + ) + ) + else: + if not password and not access_token: + missing.append("password_or_accessToken") + message = "Add a password, or an access token with its device ID." + else: + missing.append("deviceId") + message = "A device ID is required with an access token." + checks.append(check("login", "Login credentials", "fail", message)) + + checks.append( + check( + "manual_review", + "Matrix account", + "skipped", + "Room access is verified when the channel starts.", + ) + ) + return status_from_checks("matrix", checks, list(dict.fromkeys(missing))) + + +__all__ = ["validate"] diff --git a/nanobot/channels/matrix/webui/index.ts b/nanobot/channels/matrix/webui/index.ts new file mode 100644 index 00000000..1d9a0048 --- /dev/null +++ b/nanobot/channels/matrix/webui/index.ts @@ -0,0 +1,23 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "Matrix", + initials: "MX", + color: "#0DBD8B", + logoUrl: "https://matrix.org/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("matrix"), + fields: [ + { key: "channels.matrix.homeserver" }, + { key: "channels.matrix.userId" }, + { key: "channels.matrix.password" }, + { key: "channels.matrix.accessToken" }, + { key: "channels.matrix.deviceId" }, + { key: "channels.matrix.groupPolicy" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/matrix/webui/locales/en.json b/nanobot/channels/matrix/webui/locales/en.json new file mode 100644 index 00000000..cb480b7d --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/en.json @@ -0,0 +1,48 @@ +{ + "description": "Use nanobot from Matrix rooms.", + "requirements": "Homeserver, account token, room access", + "setup": { + "docsLabel": "Open Matrix setup", + "officialLabel": "Open Matrix clients", + "tryIt": "Invite the Matrix account into a room and send a test message.", + "summary": "Matrix needs a homeserver account and either password login or an access token.", + "steps": [ + "Create or choose a Matrix account for nanobot.", + "Enter the homeserver, user ID, and either password or access-token credentials.", + "Save and enable Matrix, invite the account to a room, and send a test message." + ], + "fields": { + "homeserver": { + "label": "Homeserver", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "User ID", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "Password", + "placeholder": "••••••", + "help": "Use either password login or access token login." + }, + "accessToken": { + "label": "Access token", + "placeholder": "Optional token login", + "help": "Preferred when your Matrix client exposes an access token." + }, + "deviceId": { + "label": "Device ID", + "placeholder": "Required with an access token", + "help": "Copy the device ID associated with the access token. Password login does not need it." + }, + "groupPolicy": { + "label": "Group behavior", + "choices": { + "mention": "Mention only", + "open": "All messages", + "allowlist": "Allowlist" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/es.json b/nanobot/channels/matrix/webui/locales/es.json new file mode 100644 index 00000000..e6dd36fa --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/es.json @@ -0,0 +1,48 @@ +{ + "description": "Usa nanobot desde salas de Matrix.", + "requirements": "Homeserver, token de cuenta y acceso a salas", + "setup": { + "docsLabel": "Abrir guía de Matrix", + "officialLabel": "Abrir lista de clientes Matrix", + "tryIt": "Invita la cuenta de Matrix a una sala y envía un mensaje de prueba.", + "summary": "Matrix necesita una cuenta de homeserver y acceso por contraseña o token.", + "steps": [ + "Crea o elige una cuenta de Matrix para nanobot.", + "Introduce el homeserver, ID de usuario y contraseña o token de acceso.", + "Guarda y activa Matrix, invita la cuenta a una sala y envía un mensaje de prueba." + ], + "fields": { + "homeserver": { + "label": "Homeserver", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "ID de usuario", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "Contraseña", + "placeholder": "••••••", + "help": "Usa contraseña o token de acceso." + }, + "accessToken": { + "label": "Token de acceso", + "placeholder": "Acceso opcional con token", + "help": "Preferible si tu cliente Matrix muestra un token de acceso." + }, + "deviceId": { + "label": "ID del dispositivo", + "placeholder": "Obligatorio con token", + "help": "Copia el ID asociado al token. No se necesita con contraseña." + }, + "groupPolicy": { + "label": "Comportamiento en grupos", + "choices": { + "mention": "Solo menciones", + "open": "Todos los mensajes", + "allowlist": "Lista permitida" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/fr.json b/nanobot/channels/matrix/webui/locales/fr.json new file mode 100644 index 00000000..c3b32ef7 --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/fr.json @@ -0,0 +1,48 @@ +{ + "description": "Utilisez nanobot depuis les salons Matrix.", + "requirements": "Serveur d’accueil, jeton de compte et accès aux salons", + "setup": { + "docsLabel": "Ouvrir le guide Matrix", + "officialLabel": "Ouvrir la liste des clients Matrix", + "tryIt": "Invitez le compte Matrix dans un salon et envoyez un message test.", + "summary": "Matrix nécessite un compte sur un serveur d’accueil et une connexion par mot de passe ou jeton d’accès.", + "steps": [ + "Créez ou choisissez un compte Matrix pour nanobot.", + "Saisissez le serveur, l’ID utilisateur et le mot de passe ou le jeton d’accès.", + "Enregistrez et activez Matrix, invitez le compte dans un salon et envoyez un message test." + ], + "fields": { + "homeserver": { + "label": "Serveur d’accueil", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "ID utilisateur", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "Mot de passe", + "placeholder": "••••••", + "help": "Utilisez le mot de passe ou le jeton d’accès." + }, + "accessToken": { + "label": "Jeton d’accès", + "placeholder": "Connexion facultative par jeton", + "help": "À privilégier si votre client Matrix expose un jeton d’accès." + }, + "deviceId": { + "label": "ID de l’appareil", + "placeholder": "Requis avec un jeton d’accès", + "help": "Copiez l’ID d’appareil associé au jeton. Il n’est pas nécessaire avec un mot de passe." + }, + "groupPolicy": { + "label": "Comportement en groupe", + "choices": { + "mention": "Mentions uniquement", + "open": "Tous les messages", + "allowlist": "Liste d’autorisation" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/id.json b/nanobot/channels/matrix/webui/locales/id.json new file mode 100644 index 00000000..d4ad6e32 --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/id.json @@ -0,0 +1,48 @@ +{ + "description": "Gunakan nanobot dari ruang Matrix.", + "requirements": "Homeserver, token akun, dan akses ruang", + "setup": { + "docsLabel": "Buka panduan Matrix", + "officialLabel": "Buka daftar klien Matrix", + "tryIt": "Undang akun Matrix ke ruang dan kirim pesan uji.", + "summary": "Matrix memerlukan akun homeserver dan login dengan kata sandi atau token akses.", + "steps": [ + "Buat atau pilih akun Matrix untuk nanobot.", + "Masukkan homeserver, ID pengguna, dan kata sandi atau token akses.", + "Simpan dan aktifkan Matrix, undang akun ke ruang, lalu kirim pesan uji." + ], + "fields": { + "homeserver": { + "label": "Homeserver", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "ID pengguna", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "Kata sandi", + "placeholder": "••••••", + "help": "Gunakan kata sandi atau token akses." + }, + "accessToken": { + "label": "Token akses", + "placeholder": "Login token opsional", + "help": "Disarankan jika klien Matrix menyediakan token akses." + }, + "deviceId": { + "label": "ID perangkat", + "placeholder": "Wajib dengan token akses", + "help": "Salin ID perangkat yang terkait token. Login kata sandi tidak memerlukannya." + }, + "groupPolicy": { + "label": "Perilaku grup", + "choices": { + "mention": "Hanya sebutan", + "open": "Semua pesan", + "allowlist": "Daftar izin" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/ja.json b/nanobot/channels/matrix/webui/locales/ja.json new file mode 100644 index 00000000..932d4c66 --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/ja.json @@ -0,0 +1,48 @@ +{ + "description": "Matrix ルームから nanobot を利用します。", + "requirements": "ホームサーバー、アカウントトークン、ルームへのアクセス", + "setup": { + "docsLabel": "Matrix 設定ガイドを開く", + "officialLabel": "Matrix クライアント一覧を開く", + "tryIt": "Matrix アカウントをルームに招待し、テストメッセージを送信します。", + "summary": "Matrix にはホームサーバーのアカウントと、パスワードまたはアクセストークンが必要です。", + "steps": [ + "nanobot 用の Matrix アカウントを作成または選択します。", + "ホームサーバー、ユーザー ID、パスワードまたはアクセストークンを入力します。", + "保存して Matrix を有効にし、アカウントをルームに招待してテストメッセージを送信します。" + ], + "fields": { + "homeserver": { + "label": "ホームサーバー", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "ユーザー ID", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "パスワード", + "placeholder": "••••••", + "help": "パスワードまたはアクセストークンのどちらかを使います。" + }, + "accessToken": { + "label": "アクセストークン", + "placeholder": "任意のトークンログイン", + "help": "Matrix クライアントでアクセストークンを取得できる場合に推奨します。" + }, + "deviceId": { + "label": "デバイス ID", + "placeholder": "アクセストークン使用時に必須", + "help": "トークンに関連付けられたデバイス ID をコピーします。パスワードログインでは不要です。" + }, + "groupPolicy": { + "label": "グループでの動作", + "choices": { + "mention": "メンションのみ", + "open": "すべてのメッセージ", + "allowlist": "許可リスト" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/ko.json b/nanobot/channels/matrix/webui/locales/ko.json new file mode 100644 index 00000000..d2d34a39 --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/ko.json @@ -0,0 +1,48 @@ +{ + "description": "Matrix 룸에서 nanobot을 사용합니다.", + "requirements": "홈서버, 계정 토큰 및 룸 접근 권한", + "setup": { + "docsLabel": "Matrix 설정 가이드 열기", + "officialLabel": "Matrix 클라이언트 목록 열기", + "tryIt": "Matrix 계정을 룸에 초대하고 테스트 메시지를 보내세요.", + "summary": "Matrix에는 홈서버 계정과 비밀번호 또는 액세스 토큰이 필요합니다.", + "steps": [ + "nanobot용 Matrix 계정을 만들거나 선택하세요.", + "홈서버, 사용자 ID, 비밀번호 또는 액세스 토큰을 입력하세요.", + "저장하고 Matrix를 활성화한 다음 계정을 룸에 초대하고 테스트 메시지를 보내세요." + ], + "fields": { + "homeserver": { + "label": "홈서버", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "사용자 ID", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "비밀번호", + "placeholder": "••••••", + "help": "비밀번호 또는 액세스 토큰 중 하나를 사용하세요." + }, + "accessToken": { + "label": "액세스 토큰", + "placeholder": "선택적 토큰 로그인", + "help": "Matrix 클라이언트에서 토큰을 확인할 수 있다면 권장합니다." + }, + "deviceId": { + "label": "장치 ID", + "placeholder": "액세스 토큰 사용 시 필수", + "help": "토큰과 연결된 장치 ID를 복사하세요. 비밀번호 로그인에는 필요하지 않습니다." + }, + "groupPolicy": { + "label": "그룹 동작", + "choices": { + "mention": "멘션만", + "open": "모든 메시지", + "allowlist": "허용 목록" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/pt-BR.json b/nanobot/channels/matrix/webui/locales/pt-BR.json new file mode 100644 index 00000000..ce7ef261 --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/pt-BR.json @@ -0,0 +1,48 @@ +{ + "description": "Use o nanobot em salas do Matrix.", + "requirements": "Homeserver, token da conta e acesso às salas", + "setup": { + "docsLabel": "Abrir guia do Matrix", + "officialLabel": "Abrir lista de clientes Matrix", + "tryIt": "Convide a conta Matrix para uma sala e envie uma mensagem de teste.", + "summary": "O Matrix precisa de uma conta no homeserver e login por senha ou token de acesso.", + "steps": [ + "Crie ou escolha uma conta Matrix para o nanobot.", + "Informe o homeserver, ID de usuário e senha ou token de acesso.", + "Salve e ative o Matrix, convide a conta para uma sala e envie uma mensagem de teste." + ], + "fields": { + "homeserver": { + "label": "Homeserver", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "ID de usuário", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "Senha", + "placeholder": "••••••", + "help": "Use senha ou token de acesso." + }, + "accessToken": { + "label": "Token de acesso", + "placeholder": "Login opcional por token", + "help": "Preferível quando o cliente Matrix fornece um token." + }, + "deviceId": { + "label": "ID do dispositivo", + "placeholder": "Obrigatório com token", + "help": "Copie o ID associado ao token. O login por senha não precisa dele." + }, + "groupPolicy": { + "label": "Comportamento em grupos", + "choices": { + "mention": "Somente menções", + "open": "Todas as mensagens", + "allowlist": "Lista de permissão" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/vi.json b/nanobot/channels/matrix/webui/locales/vi.json new file mode 100644 index 00000000..b10f6c14 --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/vi.json @@ -0,0 +1,48 @@ +{ + "description": "Sử dụng nanobot từ các phòng Matrix.", + "requirements": "Homeserver, token tài khoản và quyền truy cập phòng", + "setup": { + "docsLabel": "Mở hướng dẫn Matrix", + "officialLabel": "Mở danh sách ứng dụng Matrix", + "tryIt": "Mời tài khoản Matrix vào phòng và gửi tin nhắn thử.", + "summary": "Matrix cần tài khoản homeserver và đăng nhập bằng mật khẩu hoặc token truy cập.", + "steps": [ + "Tạo hoặc chọn tài khoản Matrix cho nanobot.", + "Nhập homeserver, ID người dùng và mật khẩu hoặc token truy cập.", + "Lưu và bật Matrix, mời tài khoản vào phòng rồi gửi tin nhắn thử." + ], + "fields": { + "homeserver": { + "label": "Homeserver", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "ID người dùng", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "Mật khẩu", + "placeholder": "••••••", + "help": "Dùng mật khẩu hoặc token truy cập." + }, + "accessToken": { + "label": "Token truy cập", + "placeholder": "Đăng nhập token tùy chọn", + "help": "Nên dùng khi ứng dụng Matrix cung cấp token truy cập." + }, + "deviceId": { + "label": "ID thiết bị", + "placeholder": "Bắt buộc khi dùng token", + "help": "Sao chép ID thiết bị gắn với token. Đăng nhập mật khẩu không cần." + }, + "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" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/zh-CN.json b/nanobot/channels/matrix/webui/locales/zh-CN.json new file mode 100644 index 00000000..4967c098 --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/zh-CN.json @@ -0,0 +1,48 @@ +{ + "description": "在 Matrix 房间中使用 nanobot。", + "requirements": "主服务器、账户令牌和房间访问权限", + "setup": { + "docsLabel": "打开 Matrix 配置指南", + "officialLabel": "打开 Matrix 客户端列表", + "tryIt": "将 Matrix 账户邀请进房间并发送一条测试消息。", + "summary": "Matrix 需要主服务器账户,并使用密码或访问令牌登录。", + "steps": [ + "为 nanobot 创建或选择一个 Matrix 账户。", + "填写主服务器、用户 ID,以及密码或访问令牌凭据。", + "保存并启用 Matrix,将账户邀请进房间,然后发送一条测试消息。" + ], + "fields": { + "homeserver": { + "label": "主服务器", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "用户 ID", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "密码", + "placeholder": "••••••", + "help": "密码登录和访问令牌登录任选其一。" + }, + "accessToken": { + "label": "访问令牌", + "placeholder": "可选的令牌登录", + "help": "如果 Matrix 客户端可以显示访问令牌,建议使用该方式。" + }, + "deviceId": { + "label": "设备 ID", + "placeholder": "使用访问令牌时必填", + "help": "复制与访问令牌关联的设备 ID;密码登录不需要。" + }, + "groupPolicy": { + "label": "群组行为", + "choices": { + "mention": "仅提及时", + "open": "所有消息", + "allowlist": "白名单" + } + } + } + } +} diff --git a/nanobot/channels/matrix/webui/locales/zh-TW.json b/nanobot/channels/matrix/webui/locales/zh-TW.json new file mode 100644 index 00000000..5ac6b87f --- /dev/null +++ b/nanobot/channels/matrix/webui/locales/zh-TW.json @@ -0,0 +1,48 @@ +{ + "description": "在 Matrix 房間中使用 nanobot。", + "requirements": "主伺服器、帳戶權杖和房間存取權限", + "setup": { + "docsLabel": "開啟 Matrix 設定指南", + "officialLabel": "開啟 Matrix 用戶端列表", + "tryIt": "將 Matrix 帳戶邀請進房間並傳送一則測試訊息。", + "summary": "Matrix 需要主伺服器帳戶,並使用密碼或存取權杖登入。", + "steps": [ + "為 nanobot 建立或選擇一個 Matrix 帳戶。", + "填入主伺服器、使用者 ID,以及密碼或存取權杖憑證。", + "儲存並啟用 Matrix,將帳戶邀請進房間,然後傳送一則測試訊息。" + ], + "fields": { + "homeserver": { + "label": "主伺服器", + "placeholder": "https://matrix.org" + }, + "userId": { + "label": "使用者 ID", + "placeholder": "@nanobot:matrix.org" + }, + "password": { + "label": "密碼", + "placeholder": "••••••", + "help": "密碼登入和存取權杖登入任選其一。" + }, + "accessToken": { + "label": "存取權杖", + "placeholder": "可選的權杖登入", + "help": "若 Matrix 用戶端可顯示存取權杖,建議使用此方式。" + }, + "deviceId": { + "label": "裝置 ID", + "placeholder": "使用存取權杖時必填", + "help": "複製與存取權杖關聯的裝置 ID;密碼登入不需要。" + }, + "groupPolicy": { + "label": "群組行為", + "choices": { + "mention": "僅提及時", + "open": "所有訊息", + "allowlist": "允許清單" + } + } + } + } +} diff --git a/nanobot/channels/mattermost/__init__.py b/nanobot/channels/mattermost/__init__.py new file mode 100644 index 00000000..5f5da48b --- /dev/null +++ b/nanobot/channels/mattermost/__init__.py @@ -0,0 +1 @@ +"""Mattermost channel package.""" diff --git a/nanobot/channels/mattermost/manifest.py b/nanobot/channels/mattermost/manifest.py new file mode 100644 index 00000000..575e0956 --- /dev/null +++ b/nanobot/channels/mattermost/manifest.py @@ -0,0 +1,25 @@ +"""Mattermost management contract.""" + +from nanobot.channels._manifest import GROUP_POLICIES, field, required_fields +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "serverUrl": field(), + "token": field("secret"), + "teamId": field(), + "groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"), + "allowFrom": field("list"), + }, + required=required_fields("serverUrl", "token"), + official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/", +) + +PLUGIN = ChannelPlugin( + name="mattermost", + display_name="Mattermost", + runtime=f"{__package__}.runtime:MattermostChannel", + setup=SETUP_SPEC, + webui="webui/index.ts", +) diff --git a/nanobot/channels/mattermost.py b/nanobot/channels/mattermost/runtime.py similarity index 100% rename from nanobot/channels/mattermost.py rename to nanobot/channels/mattermost/runtime.py diff --git a/nanobot/channels/mattermost/tests/__init__.py b/nanobot/channels/mattermost/tests/__init__.py new file mode 100644 index 00000000..3bf68050 --- /dev/null +++ b/nanobot/channels/mattermost/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Mattermost channel package.""" diff --git a/tests/channels/test_mattermost_channel.py b/nanobot/channels/mattermost/tests/test_mattermost_channel.py similarity index 99% rename from tests/channels/test_mattermost_channel.py rename to nanobot/channels/mattermost/tests/test_mattermost_channel.py index b3d7671a..2371fef3 100644 --- a/tests/channels/test_mattermost_channel.py +++ b/nanobot/channels/mattermost/tests/test_mattermost_channel.py @@ -12,7 +12,7 @@ import pytest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.mattermost import ( +from nanobot.channels.mattermost.runtime import ( MATTERMOST_MAX_MESSAGE_LEN, MattermostChannel, MattermostConfig, @@ -446,8 +446,8 @@ async def test_send_with_file_upload(): "file_infos": [{"id": "file_abc", "name": "test.txt"}], }) - with patch("nanobot.channels.mattermost.Path.exists", return_value=True): - with patch("nanobot.channels.mattermost.Path.read_bytes", return_value=b"data"): + with patch("nanobot.channels.mattermost.runtime.Path.exists", return_value=True): + with patch("nanobot.channels.mattermost.runtime.Path.read_bytes", return_value=b"data"): msg = OutboundMessage( channel="mattermost", chat_id="chan_1", @@ -914,7 +914,7 @@ async def test_dm_allowlist_with_username_match(): @pytest.mark.asyncio async def test_dm_allowlist_accepts_pairing_approval(): channel, fake = _make_channel({"dm": {"policy": "allowlist", "allowFrom": ["u_allowed"]}}) - with patch("nanobot.channels.mattermost.is_approved", return_value=True): + with patch("nanobot.channels.mattermost.runtime.is_approved", return_value=True): assert await channel._is_allowed("u_paired", "dm_chan", "dm") is True diff --git a/nanobot/channels/mattermost/webui/index.ts b/nanobot/channels/mattermost/webui/index.ts new file mode 100644 index 00000000..288ba3e7 --- /dev/null +++ b/nanobot/channels/mattermost/webui/index.ts @@ -0,0 +1,21 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "Mattermost", + initials: "MM", + color: "#1C58D9", + logoUrl: "https://mattermost.com/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("mattermost"), + fields: [ + { key: "channels.mattermost.serverUrl" }, + { key: "channels.mattermost.token" }, + { key: "channels.mattermost.teamId" }, + { key: "channels.mattermost.groupPolicy" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/mattermost/webui/locales/en.json b/nanobot/channels/mattermost/webui/locales/en.json new file mode 100644 index 00000000..438db9d1 --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/en.json @@ -0,0 +1,43 @@ +{ + "description": "Use nanobot from Mattermost channels and DMs.", + "requirements": "Mattermost server URL, bot token, channel access", + "setup": { + "docsLabel": "Open Mattermost setup", + "officialLabel": "Open Mattermost bot guide", + "tryIt": "Mention the bot in a Mattermost channel or send it a direct message.", + "summary": "Mattermost connects through a bot account on your server and listens for channel and direct messages.", + "steps": [ + "Create a Mattermost bot account and copy its access token.", + "Enter the server URL and grant the bot access to the target team and channels.", + "Save and enable Mattermost, then mention the bot or send a direct message." + ], + "fields": { + "serverUrl": { + "label": "Server URL", + "placeholder": "https://mattermost.example.com", + "help": "Use the base URL of your Mattermost workspace." + }, + "token": { + "label": "Bot token", + "placeholder": "Mattermost bot token", + "help": "Create this from a Mattermost bot account." + }, + "teamId": { + "label": "Team ID", + "placeholder": "Optional team ID" + }, + "groupPolicy": { + "label": "Group behavior", + "choices": { + "mention": "Mention only", + "open": "All messages", + "allowlist": "Allowlist" + } + }, + "allowFrom": { + "label": "Allowed users", + "placeholder": "User IDs, comma separated" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/es.json b/nanobot/channels/mattermost/webui/locales/es.json new file mode 100644 index 00000000..8600b196 --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/es.json @@ -0,0 +1,43 @@ +{ + "description": "Usa nanobot en canales y mensajes directos de Mattermost.", + "requirements": "URL del servidor Mattermost, token del bot y acceso a canales", + "setup": { + "docsLabel": "Abrir guía de Mattermost", + "officialLabel": "Abrir guía de bots de Mattermost", + "tryIt": "Menciona al bot en un canal de Mattermost o envíale un mensaje directo.", + "summary": "Mattermost se conecta mediante una cuenta bot en tu servidor y escucha canales y mensajes directos.", + "steps": [ + "Crea una cuenta bot de Mattermost y copia su token de acceso.", + "Introduce la URL del servidor y permite acceso al equipo y canales de destino.", + "Guarda y activa Mattermost; después menciona al bot o envíale un mensaje directo." + ], + "fields": { + "serverUrl": { + "label": "URL del servidor", + "placeholder": "https://mattermost.example.com", + "help": "Usa la URL base de tu espacio Mattermost." + }, + "token": { + "label": "Token del bot", + "placeholder": "Token del bot Mattermost", + "help": "Créalo desde una cuenta bot de Mattermost." + }, + "teamId": { + "label": "ID del equipo", + "placeholder": "ID de equipo opcional" + }, + "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" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/fr.json b/nanobot/channels/mattermost/webui/locales/fr.json new file mode 100644 index 00000000..c59759fa --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/fr.json @@ -0,0 +1,43 @@ +{ + "description": "Utilisez nanobot dans les canaux et messages privés Mattermost.", + "requirements": "URL du serveur Mattermost, jeton du bot et accès aux canaux", + "setup": { + "docsLabel": "Ouvrir le guide Mattermost", + "officialLabel": "Ouvrir le guide des bots Mattermost", + "tryIt": "Mentionnez le bot dans un canal Mattermost ou envoyez-lui un message privé.", + "summary": "Mattermost se connecte via un compte bot sur votre serveur et écoute les canaux et messages privés.", + "steps": [ + "Créez un compte bot Mattermost et copiez son jeton d’accès.", + "Saisissez l’URL du serveur et accordez l’accès à l’équipe et aux canaux cibles.", + "Enregistrez et activez Mattermost, puis mentionnez le bot ou envoyez-lui un message privé." + ], + "fields": { + "serverUrl": { + "label": "URL du serveur", + "placeholder": "https://mattermost.example.com", + "help": "Utilisez l’URL de base de votre espace Mattermost." + }, + "token": { + "label": "Jeton du bot", + "placeholder": "Jeton du bot Mattermost", + "help": "Créez-le depuis un compte bot Mattermost." + }, + "teamId": { + "label": "ID de l’équipe", + "placeholder": "ID d’équipe facultatif" + }, + "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" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/id.json b/nanobot/channels/mattermost/webui/locales/id.json new file mode 100644 index 00000000..de81e7c0 --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/id.json @@ -0,0 +1,43 @@ +{ + "description": "Gunakan nanobot dari channel dan DM Mattermost.", + "requirements": "URL server Mattermost, token bot, dan akses channel", + "setup": { + "docsLabel": "Buka panduan Mattermost", + "officialLabel": "Buka panduan bot Mattermost", + "tryIt": "Sebut bot di channel Mattermost atau kirim pesan langsung.", + "summary": "Mattermost terhubung melalui akun bot di server dan menerima pesan channel serta pesan langsung.", + "steps": [ + "Buat akun bot Mattermost dan salin token aksesnya.", + "Masukkan URL server dan berikan akses ke tim serta channel tujuan.", + "Simpan dan aktifkan Mattermost, lalu sebut bot atau kirim DM." + ], + "fields": { + "serverUrl": { + "label": "URL server", + "placeholder": "https://mattermost.example.com", + "help": "Gunakan URL dasar workspace Mattermost." + }, + "token": { + "label": "Token bot", + "placeholder": "Token bot Mattermost", + "help": "Buat dari akun bot Mattermost." + }, + "teamId": { + "label": "ID tim", + "placeholder": "ID tim opsional" + }, + "groupPolicy": { + "label": "Perilaku grup", + "choices": { + "mention": "Hanya sebutan", + "open": "Semua pesan", + "allowlist": "Daftar izin" + } + }, + "allowFrom": { + "label": "Pengguna yang diizinkan", + "placeholder": "ID pengguna, dipisahkan koma" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/ja.json b/nanobot/channels/mattermost/webui/locales/ja.json new file mode 100644 index 00000000..2db5e511 --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/ja.json @@ -0,0 +1,43 @@ +{ + "description": "Mattermost のチャンネルと DM から nanobot を利用します。", + "requirements": "Mattermost サーバー URL、ボットトークン、チャンネルアクセス", + "setup": { + "docsLabel": "Mattermost 設定ガイドを開く", + "officialLabel": "Mattermost ボットガイドを開く", + "tryIt": "Mattermost チャンネルでボットをメンションするか、DM を送信します。", + "summary": "Mattermost はサーバー上のボットアカウントで接続し、チャンネルと DM を受信します。", + "steps": [ + "Mattermost ボットアカウントを作成し、アクセストークンをコピーします。", + "サーバー URL を入力し、対象チームとチャンネルへのアクセスを許可します。", + "保存して Mattermost を有効にし、メンションまたは DM を送信します。" + ], + "fields": { + "serverUrl": { + "label": "サーバー URL", + "placeholder": "https://mattermost.example.com", + "help": "Mattermost ワークスペースのベース URL を使います。" + }, + "token": { + "label": "ボットトークン", + "placeholder": "Mattermost ボットトークン", + "help": "Mattermost ボットアカウントから作成します。" + }, + "teamId": { + "label": "チーム ID", + "placeholder": "任意のチーム ID" + }, + "groupPolicy": { + "label": "グループでの動作", + "choices": { + "mention": "メンションのみ", + "open": "すべてのメッセージ", + "allowlist": "許可リスト" + } + }, + "allowFrom": { + "label": "許可するユーザー", + "placeholder": "ユーザー ID(カンマ区切り)" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/ko.json b/nanobot/channels/mattermost/webui/locales/ko.json new file mode 100644 index 00000000..dd2cf158 --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/ko.json @@ -0,0 +1,43 @@ +{ + "description": "Mattermost 채널과 DM에서 nanobot을 사용합니다.", + "requirements": "Mattermost 서버 URL, 봇 토큰 및 채널 접근 권한", + "setup": { + "docsLabel": "Mattermost 설정 가이드 열기", + "officialLabel": "Mattermost 봇 가이드 열기", + "tryIt": "Mattermost 채널에서 봇을 멘션하거나 DM을 보내세요.", + "summary": "Mattermost는 서버의 봇 계정으로 연결해 채널과 DM을 수신합니다.", + "steps": [ + "Mattermost 봇 계정을 만들고 액세스 토큰을 복사하세요.", + "서버 URL을 입력하고 대상 팀과 채널 접근 권한을 부여하세요.", + "저장하고 Mattermost를 활성화한 다음 봇을 멘션하거나 DM을 보내세요." + ], + "fields": { + "serverUrl": { + "label": "서버 URL", + "placeholder": "https://mattermost.example.com", + "help": "Mattermost 워크스페이스 기본 URL을 사용하세요." + }, + "token": { + "label": "봇 토큰", + "placeholder": "Mattermost 봇 토큰", + "help": "Mattermost 봇 계정에서 생성하세요." + }, + "teamId": { + "label": "팀 ID", + "placeholder": "선택적 팀 ID" + }, + "groupPolicy": { + "label": "그룹 동작", + "choices": { + "mention": "멘션만", + "open": "모든 메시지", + "allowlist": "허용 목록" + } + }, + "allowFrom": { + "label": "허용된 사용자", + "placeholder": "사용자 ID, 쉼표로 구분" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/pt-BR.json b/nanobot/channels/mattermost/webui/locales/pt-BR.json new file mode 100644 index 00000000..f55c7d75 --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/pt-BR.json @@ -0,0 +1,43 @@ +{ + "description": "Use o nanobot em canais e DMs do Mattermost.", + "requirements": "URL do servidor Mattermost, token do bot e acesso aos canais", + "setup": { + "docsLabel": "Abrir guia do Mattermost", + "officialLabel": "Abrir guia de bots do Mattermost", + "tryIt": "Mencione o bot em um canal Mattermost ou envie uma mensagem direta.", + "summary": "O Mattermost conecta por uma conta de bot no servidor e recebe canais e mensagens diretas.", + "steps": [ + "Crie uma conta de bot Mattermost e copie o token de acesso.", + "Informe a URL do servidor e conceda acesso à equipe e aos canais desejados.", + "Salve e ative o Mattermost; depois, mencione o bot ou envie uma DM." + ], + "fields": { + "serverUrl": { + "label": "URL do servidor", + "placeholder": "https://mattermost.example.com", + "help": "Use a URL base do seu workspace Mattermost." + }, + "token": { + "label": "Token do bot", + "placeholder": "Token do bot Mattermost", + "help": "Crie-o em uma conta de bot Mattermost." + }, + "teamId": { + "label": "ID da equipe", + "placeholder": "ID de equipe opcional" + }, + "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" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/vi.json b/nanobot/channels/mattermost/webui/locales/vi.json new file mode 100644 index 00000000..5e978ef6 --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/vi.json @@ -0,0 +1,43 @@ +{ + "description": "Sử dụng nanobot trong kênh và tin nhắn riêng Mattermost.", + "requirements": "URL máy chủ Mattermost, token bot và quyền truy cập kênh", + "setup": { + "docsLabel": "Mở hướng dẫn Mattermost", + "officialLabel": "Mở hướng dẫn bot Mattermost", + "tryIt": "Nhắc bot trong kênh Mattermost hoặc gửi tin nhắn riêng.", + "summary": "Mattermost kết nối qua tài khoản bot trên máy chủ và nhận tin nhắn kênh cùng tin nhắn riêng.", + "steps": [ + "Tạo tài khoản bot Mattermost và sao chép token truy cập.", + "Nhập URL máy chủ và cấp quyền vào nhóm cùng các kênh đích.", + "Lưu và bật Mattermost, sau đó nhắc bot hoặc gửi tin nhắn riêng." + ], + "fields": { + "serverUrl": { + "label": "URL máy chủ", + "placeholder": "https://mattermost.example.com", + "help": "Dùng URL gốc của không gian Mattermost." + }, + "token": { + "label": "Token bot", + "placeholder": "Token bot Mattermost", + "help": "Tạo từ tài khoản bot Mattermost." + }, + "teamId": { + "label": "ID nhóm", + "placeholder": "ID nhóm tùy chọn" + }, + "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" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/zh-CN.json b/nanobot/channels/mattermost/webui/locales/zh-CN.json new file mode 100644 index 00000000..0a8db0ae --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/zh-CN.json @@ -0,0 +1,43 @@ +{ + "description": "在 Mattermost 频道和私信中使用 nanobot。", + "requirements": "Mattermost 服务器地址、机器人令牌和频道权限", + "setup": { + "docsLabel": "打开 Mattermost 配置指南", + "officialLabel": "打开 Mattermost 机器人指南", + "tryIt": "在 Mattermost 频道中提及机器人,或向它发送私信。", + "summary": "Mattermost 通过服务器中的机器人账户连接,并监听频道和私信。", + "steps": [ + "创建 Mattermost 机器人账户并复制访问令牌。", + "填写服务器地址,并授予机器人访问目标团队和频道的权限。", + "保存并启用 Mattermost,然后提及机器人或发送私信。" + ], + "fields": { + "serverUrl": { + "label": "服务器地址", + "placeholder": "https://mattermost.example.com", + "help": "填写 Mattermost 工作区的基础地址。" + }, + "token": { + "label": "机器人令牌", + "placeholder": "Mattermost 机器人令牌", + "help": "从 Mattermost 机器人账户创建。" + }, + "teamId": { + "label": "团队 ID", + "placeholder": "可选的团队 ID" + }, + "groupPolicy": { + "label": "群组行为", + "choices": { + "mention": "仅提及时", + "open": "所有消息", + "allowlist": "白名单" + } + }, + "allowFrom": { + "label": "允许的用户", + "placeholder": "用户 ID,用逗号分隔" + } + } + } +} diff --git a/nanobot/channels/mattermost/webui/locales/zh-TW.json b/nanobot/channels/mattermost/webui/locales/zh-TW.json new file mode 100644 index 00000000..78d15dff --- /dev/null +++ b/nanobot/channels/mattermost/webui/locales/zh-TW.json @@ -0,0 +1,43 @@ +{ + "description": "在 Mattermost 頻道和私訊中使用 nanobot。", + "requirements": "Mattermost 伺服器網址、機器人權杖和頻道權限", + "setup": { + "docsLabel": "開啟 Mattermost 設定指南", + "officialLabel": "開啟 Mattermost 機器人指南", + "tryIt": "在 Mattermost 頻道中提及機器人,或向它傳送私訊。", + "summary": "Mattermost 透過伺服器中的機器人帳戶連線,並監聽頻道和私訊。", + "steps": [ + "建立 Mattermost 機器人帳戶並複製存取權杖。", + "填入伺服器網址,並授予機器人存取目標團隊和頻道的權限。", + "儲存並啟用 Mattermost,然後提及機器人或傳送私訊。" + ], + "fields": { + "serverUrl": { + "label": "伺服器網址", + "placeholder": "https://mattermost.example.com", + "help": "填入 Mattermost 工作區的基礎網址。" + }, + "token": { + "label": "機器人權杖", + "placeholder": "Mattermost 機器人權杖", + "help": "從 Mattermost 機器人帳戶建立。" + }, + "teamId": { + "label": "團隊 ID", + "placeholder": "可選的團隊 ID" + }, + "groupPolicy": { + "label": "群組行為", + "choices": { + "mention": "僅提及時", + "open": "所有訊息", + "allowlist": "允許清單" + } + }, + "allowFrom": { + "label": "允許的使用者", + "placeholder": "使用者 ID,以逗號分隔" + } + } + } +} diff --git a/nanobot/channels/mochat/__init__.py b/nanobot/channels/mochat/__init__.py new file mode 100644 index 00000000..b4ba65e9 --- /dev/null +++ b/nanobot/channels/mochat/__init__.py @@ -0,0 +1 @@ +"""MoChat channel package.""" diff --git a/nanobot/channels/mochat/manifest.py b/nanobot/channels/mochat/manifest.py new file mode 100644 index 00000000..6318e45a --- /dev/null +++ b/nanobot/channels/mochat/manifest.py @@ -0,0 +1,30 @@ +"""MoChat management contract.""" + +from nanobot.channels._manifest import field, required +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "baseUrl": field(default="https://mochat.io"), + "clawToken": field("secret"), + "agentUserId": field(), + "sessions": field("list"), + "panels": field("list"), + "allowFrom": field("list"), + }, + required=(required("clawToken"),), + official_url="https://mochat.io/", +) + +PLUGIN = ChannelPlugin( + name="mochat", + display_name="MoChat", + runtime=f"{__package__}.runtime:MochatChannel", + setup=SETUP_SPEC, + dependencies=( + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + ), + settings_visible=False, +) diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat/runtime.py similarity index 100% rename from nanobot/channels/mochat.py rename to nanobot/channels/mochat/runtime.py diff --git a/nanobot/channels/msteams/__init__.py b/nanobot/channels/msteams/__init__.py new file mode 100644 index 00000000..bed6c2bf --- /dev/null +++ b/nanobot/channels/msteams/__init__.py @@ -0,0 +1 @@ +"""Microsoft Teams channel package.""" diff --git a/nanobot/channels/msteams/manifest.py b/nanobot/channels/msteams/manifest.py new file mode 100644 index 00000000..5b298e4a --- /dev/null +++ b/nanobot/channels/msteams/manifest.py @@ -0,0 +1,29 @@ +"""Microsoft Teams 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={ + "appId": field(), + "appPassword": field("secret"), + "tenantId": field(), + "path": field(default="/api/messages"), + "allowFrom": field("list"), + }, + required=required_fields("appId", "appPassword"), + official_url="https://dev.teams.microsoft.com/apps", +) + +PLUGIN = ChannelPlugin( + name="msteams", + display_name="Microsoft Teams", + runtime=f"{__package__}.runtime:MSTeamsChannel", + setup=SETUP_SPEC, + dependencies=( + "PyJWT>=2.0,<3.0", + "cryptography>=41.0", + ), + webui="webui/index.ts", +) diff --git a/nanobot/channels/msteams.py b/nanobot/channels/msteams/runtime.py similarity index 99% rename from nanobot/channels/msteams.py rename to nanobot/channels/msteams/runtime.py index f989cfb1..addb4164 100644 --- a/nanobot/channels/msteams.py +++ b/nanobot/channels/msteams/runtime.py @@ -163,7 +163,7 @@ class MSTeamsChannel(BaseChannel): channel = self class Handler(BaseHTTPRequestHandler): - def do_POST(self) -> None: + def do_POST(self) -> None: # noqa: N802 if self.path != channel.config.path: self.send_response(404) self.end_headers() diff --git a/nanobot/channels/msteams/tests/__init__.py b/nanobot/channels/msteams/tests/__init__.py new file mode 100644 index 00000000..01e76036 --- /dev/null +++ b/nanobot/channels/msteams/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Microsoft Teams channel package.""" diff --git a/tests/test_msteams.py b/nanobot/channels/msteams/tests/test_msteams.py similarity index 99% rename from tests/test_msteams.py rename to nanobot/channels/msteams/tests/test_msteams.py index aa6d8c11..cb3fa9ac 100644 --- a/tests/test_msteams.py +++ b/nanobot/channels/msteams/tests/test_msteams.py @@ -5,8 +5,9 @@ import pytest # Check optional msteams dependencies before running tests try: - from nanobot.channels import msteams - MSTEAMS_AVAILABLE = getattr(msteams, "MSTEAMS_AVAILABLE", False) + import nanobot.channels.msteams.runtime as msteams_module + + MSTEAMS_AVAILABLE = msteams_module.MSTEAMS_AVAILABLE except ImportError: MSTEAMS_AVAILABLE = False @@ -20,9 +21,8 @@ if not MSTEAMS_AVAILABLE: import jwt from cryptography.hazmat.primitives.asymmetric import rsa -import nanobot.channels.msteams as msteams_module from nanobot.bus.events import OutboundMessage -from nanobot.channels.msteams import ConversationRef, MSTeamsChannel +from nanobot.channels.msteams.runtime import ConversationRef, MSTeamsChannel class DummyBus: @@ -63,7 +63,7 @@ class FakeHttpClient: @pytest.fixture def make_channel(tmp_path, monkeypatch): - monkeypatch.setattr("nanobot.channels.msteams.get_workspace_path", lambda: tmp_path) + monkeypatch.setattr("nanobot.channels.msteams.runtime.get_workspace_path", lambda: tmp_path) def _make_channel(**config_overrides): config = { diff --git a/nanobot/channels/msteams/webui/index.ts b/nanobot/channels/msteams/webui/index.ts new file mode 100644 index 00000000..7360fd0d --- /dev/null +++ b/nanobot/channels/msteams/webui/index.ts @@ -0,0 +1,22 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "Microsoft Teams", + initials: "MS", + color: "#6264A7", + logoUrl: "https://www.microsoft.com/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("msteams"), + fields: [ + { key: "channels.msteams.appId" }, + { key: "channels.msteams.appPassword" }, + { key: "channels.msteams.tenantId" }, + { key: "channels.msteams.path" }, + { key: "channels.msteams.allowFrom" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/msteams/webui/locales/en.json b/nanobot/channels/msteams/webui/locales/en.json new file mode 100644 index 00000000..9f41278f --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/en.json @@ -0,0 +1,39 @@ +{ + "description": "Use nanobot from Microsoft Teams chats.", + "requirements": "Azure bot app credentials, public callback endpoint", + "setup": { + "docsLabel": "Open Teams setup", + "officialLabel": "Open Teams developer portal", + "tryIt": "Install the Teams app and send a test message.", + "summary": "Teams receives messages through the Bot Framework callback URL. It needs a reachable HTTPS endpoint in production.", + "steps": [ + "Create an Azure Bot or Teams app and copy its App ID and client secret.", + "Expose the callback path over HTTPS and add it as the bot messaging endpoint.", + "Save and enable Teams, then install the app and send a test message." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Copy it from the Azure Bot or Teams app registration." + }, + "appPassword": { + "label": "Client secret", + "placeholder": "••••••", + "help": "Create a client secret for the Microsoft app." + }, + "tenantId": { + "label": "Tenant ID", + "placeholder": "Optional tenant ID" + }, + "path": { + "label": "Callback path", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "Allowed users", + "placeholder": "Teams user IDs, comma separated" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/es.json b/nanobot/channels/msteams/webui/locales/es.json new file mode 100644 index 00000000..d7eadcab --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/es.json @@ -0,0 +1,39 @@ +{ + "description": "Usa nanobot desde chats de Microsoft Teams.", + "requirements": "Credenciales de la app bot de Azure y endpoint público de callback", + "setup": { + "docsLabel": "Abrir guía de Teams", + "officialLabel": "Abrir portal de desarrolladores de Teams", + "tryIt": "Instala la app de Teams y envía un mensaje de prueba.", + "summary": "Teams recibe mensajes mediante la URL de callback de Bot Framework. En producción necesita un endpoint HTTPS accesible.", + "steps": [ + "Crea un Azure Bot o una app de Teams y copia el App ID y el secreto de cliente.", + "Expón la ruta de callback por HTTPS y añádela como endpoint de mensajería.", + "Guarda y activa Teams; después instala la app y envía un mensaje de prueba." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Cópialo del registro de Azure Bot o la app de Teams." + }, + "appPassword": { + "label": "Secreto de cliente", + "placeholder": "••••••", + "help": "Crea un secreto de cliente para la app de Microsoft." + }, + "tenantId": { + "label": "ID del tenant", + "placeholder": "ID de tenant opcional" + }, + "path": { + "label": "Ruta de callback", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "Usuarios permitidos", + "placeholder": "ID de usuario de Teams separados por comas" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/fr.json b/nanobot/channels/msteams/webui/locales/fr.json new file mode 100644 index 00000000..0b875639 --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/fr.json @@ -0,0 +1,39 @@ +{ + "description": "Utilisez nanobot depuis les conversations Microsoft Teams.", + "requirements": "Identifiants de l’application bot Azure et endpoint de rappel public", + "setup": { + "docsLabel": "Ouvrir le guide Teams", + "officialLabel": "Ouvrir le portail développeur Teams", + "tryIt": "Installez l’application Teams et envoyez un message test.", + "summary": "Teams reçoit les messages via l’URL de rappel Bot Framework. Un endpoint HTTPS accessible est nécessaire en production.", + "steps": [ + "Créez un bot Azure ou une application Teams et copiez l’App ID et le secret client.", + "Exposez le chemin de rappel en HTTPS et définissez-le comme endpoint de messagerie.", + "Enregistrez et activez Teams, puis installez l’application et envoyez un message test." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Copiez-le depuis l’inscription du bot Azure ou de l’application Teams." + }, + "appPassword": { + "label": "Secret client", + "placeholder": "••••••", + "help": "Créez un secret client pour l’application Microsoft." + }, + "tenantId": { + "label": "ID du locataire", + "placeholder": "ID de locataire facultatif" + }, + "path": { + "label": "Chemin de rappel", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "Utilisateurs autorisés", + "placeholder": "ID utilisateur Teams séparés par des virgules" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/id.json b/nanobot/channels/msteams/webui/locales/id.json new file mode 100644 index 00000000..daa83248 --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/id.json @@ -0,0 +1,39 @@ +{ + "description": "Gunakan nanobot dari chat Microsoft Teams.", + "requirements": "Kredensial aplikasi bot Azure dan endpoint callback publik", + "setup": { + "docsLabel": "Buka panduan Teams", + "officialLabel": "Buka portal pengembang Teams", + "tryIt": "Pasang aplikasi Teams dan kirim pesan uji.", + "summary": "Teams menerima pesan melalui URL callback Bot Framework. Produksi memerlukan endpoint HTTPS yang dapat dijangkau.", + "steps": [ + "Buat Azure Bot atau aplikasi Teams dan salin App ID serta client secret.", + "Paparkan jalur callback melalui HTTPS dan jadikan endpoint pesan bot.", + "Simpan dan aktifkan Teams, lalu pasang aplikasi dan kirim pesan uji." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Salin dari pendaftaran Azure Bot atau aplikasi Teams." + }, + "appPassword": { + "label": "Client secret", + "placeholder": "••••••", + "help": "Buat client secret untuk aplikasi Microsoft." + }, + "tenantId": { + "label": "Tenant ID", + "placeholder": "Tenant ID opsional" + }, + "path": { + "label": "Jalur callback", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "Pengguna yang diizinkan", + "placeholder": "ID pengguna Teams, dipisahkan koma" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/ja.json b/nanobot/channels/msteams/webui/locales/ja.json new file mode 100644 index 00000000..3e82dc7d --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/ja.json @@ -0,0 +1,39 @@ +{ + "description": "Microsoft Teams のチャットから nanobot を利用します。", + "requirements": "Azure ボットアプリの認証情報と公開コールバックエンドポイント", + "setup": { + "docsLabel": "Teams 設定ガイドを開く", + "officialLabel": "Teams 開発者ポータルを開く", + "tryIt": "Teams アプリをインストールし、テストメッセージを送信します。", + "summary": "Teams は Bot Framework のコールバック URL でメッセージを受信します。本番環境では到達可能な HTTPS エンドポイントが必要です。", + "steps": [ + "Azure Bot または Teams アプリを作成し、App ID とクライアントシークレットをコピーします。", + "コールバックパスを HTTPS で公開し、ボットのメッセージングエンドポイントに設定します。", + "保存して Teams を有効にし、アプリをインストールしてテストメッセージを送信します。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Azure Bot または Teams アプリ登録からコピーします。" + }, + "appPassword": { + "label": "クライアントシークレット", + "placeholder": "••••••", + "help": "Microsoft アプリ用のクライアントシークレットを作成します。" + }, + "tenantId": { + "label": "テナント ID", + "placeholder": "任意のテナント ID" + }, + "path": { + "label": "コールバックパス", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "許可するユーザー", + "placeholder": "Teams ユーザー ID(カンマ区切り)" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/ko.json b/nanobot/channels/msteams/webui/locales/ko.json new file mode 100644 index 00000000..31a828a2 --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/ko.json @@ -0,0 +1,39 @@ +{ + "description": "Microsoft Teams 채팅에서 nanobot을 사용합니다.", + "requirements": "Azure 봇 앱 자격 증명 및 공개 콜백 엔드포인트", + "setup": { + "docsLabel": "Teams 설정 가이드 열기", + "officialLabel": "Teams 개발자 포털 열기", + "tryIt": "Teams 앱을 설치하고 테스트 메시지를 보내세요.", + "summary": "Teams는 Bot Framework 콜백 URL로 메시지를 받습니다. 운영 환경에는 접근 가능한 HTTPS 엔드포인트가 필요합니다.", + "steps": [ + "Azure Bot 또는 Teams 앱을 만들고 App ID와 클라이언트 Secret을 복사하세요.", + "콜백 경로를 HTTPS로 공개하고 봇 메시징 엔드포인트로 설정하세요.", + "저장하고 Teams를 활성화한 다음 앱을 설치하고 테스트 메시지를 보내세요." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Azure Bot 또는 Teams 앱 등록에서 복사하세요." + }, + "appPassword": { + "label": "클라이언트 Secret", + "placeholder": "••••••", + "help": "Microsoft 앱용 클라이언트 Secret을 생성하세요." + }, + "tenantId": { + "label": "테넌트 ID", + "placeholder": "선택적 테넌트 ID" + }, + "path": { + "label": "콜백 경로", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "허용된 사용자", + "placeholder": "Teams 사용자 ID, 쉼표로 구분" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/pt-BR.json b/nanobot/channels/msteams/webui/locales/pt-BR.json new file mode 100644 index 00000000..1b7850a3 --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/pt-BR.json @@ -0,0 +1,39 @@ +{ + "description": "Use o nanobot em conversas do Microsoft Teams.", + "requirements": "Credenciais do app bot Azure e endpoint público de callback", + "setup": { + "docsLabel": "Abrir guia do Teams", + "officialLabel": "Abrir portal de desenvolvedores do Teams", + "tryIt": "Instale o app do Teams e envie uma mensagem de teste.", + "summary": "O Teams recebe mensagens pela URL de callback do Bot Framework. Em produção, precisa de um endpoint HTTPS acessível.", + "steps": [ + "Crie um Azure Bot ou app Teams e copie o App ID e o segredo do cliente.", + "Exponha o caminho de callback por HTTPS e defina-o como endpoint de mensagens.", + "Salve e ative o Teams; depois, instale o app e envie uma mensagem de teste." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Copie do registro do Azure Bot ou do app Teams." + }, + "appPassword": { + "label": "Segredo do cliente", + "placeholder": "••••••", + "help": "Crie um segredo do cliente para o app Microsoft." + }, + "tenantId": { + "label": "ID do locatário", + "placeholder": "ID de locatário opcional" + }, + "path": { + "label": "Caminho de callback", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "Usuários permitidos", + "placeholder": "IDs de usuário do Teams separados por vírgulas" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/vi.json b/nanobot/channels/msteams/webui/locales/vi.json new file mode 100644 index 00000000..14c14d33 --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/vi.json @@ -0,0 +1,39 @@ +{ + "description": "Sử dụng nanobot từ các cuộc trò chuyện Microsoft Teams.", + "requirements": "Thông tin xác thực bot Azure và endpoint callback công khai", + "setup": { + "docsLabel": "Mở hướng dẫn Teams", + "officialLabel": "Mở cổng nhà phát triển Teams", + "tryIt": "Cài ứng dụng Teams và gửi tin nhắn thử.", + "summary": "Teams nhận tin nhắn qua URL callback của Bot Framework. Môi trường production cần endpoint HTTPS có thể truy cập.", + "steps": [ + "Tạo Azure Bot hoặc ứng dụng Teams và sao chép App ID cùng client secret.", + "Công khai đường dẫn callback qua HTTPS và đặt làm endpoint nhắn tin của bot.", + "Lưu và bật Teams, sau đó cài ứng dụng và gửi tin nhắn thử." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "Sao chép từ đăng ký Azure Bot hoặc ứng dụng Teams." + }, + "appPassword": { + "label": "Client secret", + "placeholder": "••••••", + "help": "Tạo client secret cho ứng dụng Microsoft." + }, + "tenantId": { + "label": "Tenant ID", + "placeholder": "Tenant ID tùy chọn" + }, + "path": { + "label": "Đường dẫn callback", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "Người dùng được phép", + "placeholder": "ID người dùng Teams, phân tách bằng dấu phẩy" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/zh-CN.json b/nanobot/channels/msteams/webui/locales/zh-CN.json new file mode 100644 index 00000000..9b8f46ac --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/zh-CN.json @@ -0,0 +1,39 @@ +{ + "description": "在 Microsoft Teams 会话中使用 nanobot。", + "requirements": "Azure 机器人应用凭据和公网回调地址", + "setup": { + "docsLabel": "打开 Teams 配置指南", + "officialLabel": "打开 Teams 开发者后台", + "tryIt": "安装 Teams 应用并发送一条测试消息。", + "summary": "Teams 通过 Bot Framework 回调地址接收消息;生产环境需要可访问的 HTTPS 端点。", + "steps": [ + "创建 Azure Bot 或 Teams 应用,并复制 App ID 和客户端密钥。", + "通过 HTTPS 暴露回调路径,并将其设为机器人的消息端点。", + "保存并启用 Teams,然后安装应用并发送一条测试消息。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "从 Azure Bot 或 Teams 应用注册页面复制。" + }, + "appPassword": { + "label": "客户端密钥", + "placeholder": "••••••", + "help": "为 Microsoft 应用创建客户端密钥。" + }, + "tenantId": { + "label": "租户 ID", + "placeholder": "可选的租户 ID" + }, + "path": { + "label": "回调路径", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "允许的用户", + "placeholder": "Teams 用户 ID,用逗号分隔" + } + } + } +} diff --git a/nanobot/channels/msteams/webui/locales/zh-TW.json b/nanobot/channels/msteams/webui/locales/zh-TW.json new file mode 100644 index 00000000..d8de6cb7 --- /dev/null +++ b/nanobot/channels/msteams/webui/locales/zh-TW.json @@ -0,0 +1,39 @@ +{ + "description": "在 Microsoft Teams 對話中使用 nanobot。", + "requirements": "Azure 機器人應用程式憑證和公開回呼端點", + "setup": { + "docsLabel": "開啟 Teams 設定指南", + "officialLabel": "開啟 Teams 開發者後台", + "tryIt": "安裝 Teams 應用程式並傳送一則測試訊息。", + "summary": "Teams 透過 Bot Framework 回呼網址接收訊息;正式環境需要可存取的 HTTPS 端點。", + "steps": [ + "建立 Azure Bot 或 Teams 應用程式,並複製 App ID 和用戶端密鑰。", + "透過 HTTPS 公開回呼路徑,並將其設為機器人的訊息端點。", + "儲存並啟用 Teams,然後安裝應用程式並傳送一則測試訊息。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "Microsoft App ID", + "help": "從 Azure Bot 或 Teams 應用程式註冊頁面複製。" + }, + "appPassword": { + "label": "用戶端密鑰", + "placeholder": "••••••", + "help": "為 Microsoft 應用程式建立用戶端密鑰。" + }, + "tenantId": { + "label": "租用戶 ID", + "placeholder": "可選的租用戶 ID" + }, + "path": { + "label": "回呼路徑", + "placeholder": "/api/messages" + }, + "allowFrom": { + "label": "允許的使用者", + "placeholder": "Teams 使用者 ID,以逗號分隔" + } + } + } +} diff --git a/nanobot/channels/napcat/__init__.py b/nanobot/channels/napcat/__init__.py new file mode 100644 index 00000000..a498799f --- /dev/null +++ b/nanobot/channels/napcat/__init__.py @@ -0,0 +1 @@ +"""NapCat channel package.""" diff --git a/nanobot/channels/napcat/manifest.py b/nanobot/channels/napcat/manifest.py new file mode 100644 index 00000000..c4923d42 --- /dev/null +++ b/nanobot/channels/napcat/manifest.py @@ -0,0 +1,25 @@ +"""NapCat management contract.""" + +from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field, required +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "wsUrl": field(default="ws://127.0.0.1:3001"), + "accessToken": field("secret"), + "allowFrom": field("list"), + "groupPolicy": field("enum", choices=DIRECT_GROUP_POLICIES, default="mention"), + }, + required=(required("wsUrl"),), + official_url="https://napneko.github.io/", +) + +PLUGIN = ChannelPlugin( + name="napcat", + display_name="NapCat", + runtime=f"{__package__}.runtime:NapcatChannel", + setup=SETUP_SPEC, + dependencies=("aiohttp>=3.9.0,<4.0.0",), + webui="webui/index.ts", +) diff --git a/nanobot/channels/napcat.py b/nanobot/channels/napcat/runtime.py similarity index 100% rename from nanobot/channels/napcat.py rename to nanobot/channels/napcat/runtime.py diff --git a/nanobot/channels/napcat/tests/__init__.py b/nanobot/channels/napcat/tests/__init__.py new file mode 100644 index 00000000..c4c0d772 --- /dev/null +++ b/nanobot/channels/napcat/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the NapCat channel package.""" diff --git a/tests/channels/test_napcat_channel.py b/nanobot/channels/napcat/tests/test_napcat_channel.py similarity index 97% rename from tests/channels/test_napcat_channel.py rename to nanobot/channels/napcat/tests/test_napcat_channel.py index 134bfc52..0b89e3a7 100644 --- a/tests/channels/test_napcat_channel.py +++ b/nanobot/channels/napcat/tests/test_napcat_channel.py @@ -4,7 +4,7 @@ import pytest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.napcat import NapcatChannel, NapcatConfig +from nanobot.channels.napcat.runtime import NapcatChannel, NapcatConfig class _FakeWs: @@ -150,7 +150,7 @@ async def test_download_image_rejects_redirects(tmp_path, monkeypatch) -> None: channel._media_root = tmp_path channel._http = _FakeHttp(_FakeResponse(status=302)) monkeypatch.setattr( - "nanobot.channels.napcat.validate_url_target", + "nanobot.channels.napcat.runtime.validate_url_target", lambda _url: (True, ""), ) diff --git a/nanobot/channels/napcat/webui/index.ts b/nanobot/channels/napcat/webui/index.ts new file mode 100644 index 00000000..11bfbc21 --- /dev/null +++ b/nanobot/channels/napcat/webui/index.ts @@ -0,0 +1,21 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "NapCat", + initials: "NC", + color: "#F97316", + logoUrl: "https://napneko.github.io/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("napcat"), + fields: [ + { key: "channels.napcat.wsUrl" }, + { key: "channels.napcat.accessToken" }, + { key: "channels.napcat.groupPolicy" }, + { key: "channels.napcat.allowFrom" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/napcat/webui/locales/en.json b/nanobot/channels/napcat/webui/locales/en.json new file mode 100644 index 00000000..891ca805 --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/en.json @@ -0,0 +1,38 @@ +{ + "description": "Connect nanobot through a NapCat gateway.", + "requirements": "NapCat WebSocket endpoint, optional access token", + "setup": { + "docsLabel": "Open NapCat setup", + "officialLabel": "Open NapCat docs", + "tryIt": "Send a QQ test message through NapCat.", + "summary": "NapCat connects nanobot to QQ through a local or remote OneBot WebSocket endpoint.", + "steps": [ + "Start NapCat and enable a Forward WebSocket service.", + "Copy the WebSocket URL and optional access token into nanobot.", + "Save and enable NapCat, then send a QQ test message." + ], + "fields": { + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "ws://127.0.0.1:3001", + "help": "Use the Forward WebSocket URL from NapCat." + }, + "accessToken": { + "label": "Access token", + "placeholder": "Optional token" + }, + "groupPolicy": { + "label": "Group behavior", + "choices": { + "mention": "Mention only", + "open": "All messages", + "allowlist": "Allowlist" + } + }, + "allowFrom": { + "label": "Allowed users", + "placeholder": "QQ IDs, comma separated" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/es.json b/nanobot/channels/napcat/webui/locales/es.json new file mode 100644 index 00000000..5769c04b --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/es.json @@ -0,0 +1,38 @@ +{ + "description": "Conecta nanobot mediante un gateway NapCat.", + "requirements": "Endpoint WebSocket de NapCat y token de acceso opcional", + "setup": { + "docsLabel": "Abrir guía de NapCat", + "officialLabel": "Abrir documentación de NapCat", + "tryIt": "Envía un mensaje de prueba de QQ mediante NapCat.", + "summary": "NapCat conecta nanobot con QQ mediante un endpoint WebSocket OneBot local o remoto.", + "steps": [ + "Inicia NapCat y activa un servicio Forward WebSocket.", + "Copia la URL WebSocket y el token opcional en nanobot.", + "Guarda y activa NapCat; después envía un mensaje de prueba de QQ." + ], + "fields": { + "wsUrl": { + "label": "URL WebSocket", + "placeholder": "ws://127.0.0.1:3001", + "help": "Usa la URL Forward WebSocket de NapCat." + }, + "accessToken": { + "label": "Token de acceso", + "placeholder": "Token opcional" + }, + "groupPolicy": { + "label": "Comportamiento en grupos", + "choices": { + "mention": "Solo menciones", + "open": "Todos los mensajes", + "allowlist": "Lista permitida" + } + }, + "allowFrom": { + "label": "Usuarios permitidos", + "placeholder": "ID de QQ separados por comas" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/fr.json b/nanobot/channels/napcat/webui/locales/fr.json new file mode 100644 index 00000000..48cf1814 --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/fr.json @@ -0,0 +1,38 @@ +{ + "description": "Connectez nanobot via une passerelle NapCat.", + "requirements": "Endpoint WebSocket NapCat et jeton d’accès facultatif", + "setup": { + "docsLabel": "Ouvrir le guide NapCat", + "officialLabel": "Ouvrir la documentation NapCat", + "tryIt": "Envoyez un message test QQ via NapCat.", + "summary": "NapCat relie nanobot à QQ via un endpoint WebSocket OneBot local ou distant.", + "steps": [ + "Démarrez NapCat et activez un service Forward WebSocket.", + "Copiez l’URL WebSocket et le jeton facultatif dans nanobot.", + "Enregistrez et activez NapCat, puis envoyez un message test QQ." + ], + "fields": { + "wsUrl": { + "label": "URL WebSocket", + "placeholder": "ws://127.0.0.1:3001", + "help": "Utilisez l’URL Forward WebSocket de NapCat." + }, + "accessToken": { + "label": "Jeton d’accès", + "placeholder": "Jeton facultatif" + }, + "groupPolicy": { + "label": "Comportement en groupe", + "choices": { + "mention": "Mentions uniquement", + "open": "Tous les messages", + "allowlist": "Liste d’autorisation" + } + }, + "allowFrom": { + "label": "Utilisateurs autorisés", + "placeholder": "ID QQ séparés par des virgules" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/id.json b/nanobot/channels/napcat/webui/locales/id.json new file mode 100644 index 00000000..f4dd4883 --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/id.json @@ -0,0 +1,38 @@ +{ + "description": "Hubungkan nanobot melalui gateway NapCat.", + "requirements": "Endpoint WebSocket NapCat dan token akses opsional", + "setup": { + "docsLabel": "Buka panduan NapCat", + "officialLabel": "Buka dokumentasi NapCat", + "tryIt": "Kirim pesan uji QQ melalui NapCat.", + "summary": "NapCat menghubungkan nanobot ke QQ melalui endpoint OneBot WebSocket lokal atau jarak jauh.", + "steps": [ + "Mulai NapCat dan aktifkan layanan Forward WebSocket.", + "Salin URL WebSocket dan token opsional ke nanobot.", + "Simpan dan aktifkan NapCat, lalu kirim pesan uji QQ." + ], + "fields": { + "wsUrl": { + "label": "URL WebSocket", + "placeholder": "ws://127.0.0.1:3001", + "help": "Gunakan URL Forward WebSocket dari NapCat." + }, + "accessToken": { + "label": "Token akses", + "placeholder": "Token opsional" + }, + "groupPolicy": { + "label": "Perilaku grup", + "choices": { + "mention": "Hanya sebutan", + "open": "Semua pesan", + "allowlist": "Daftar izin" + } + }, + "allowFrom": { + "label": "Pengguna yang diizinkan", + "placeholder": "ID QQ, dipisahkan koma" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/ja.json b/nanobot/channels/napcat/webui/locales/ja.json new file mode 100644 index 00000000..0ce71add --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/ja.json @@ -0,0 +1,38 @@ +{ + "description": "NapCat ゲートウェイ経由で nanobot を接続します。", + "requirements": "NapCat WebSocket エンドポイントと任意のアクセストークン", + "setup": { + "docsLabel": "NapCat 設定ガイドを開く", + "officialLabel": "NapCat ドキュメントを開く", + "tryIt": "NapCat 経由で QQ のテストメッセージを送信します。", + "summary": "NapCat はローカルまたはリモートの OneBot WebSocket で nanobot を QQ に接続します。", + "steps": [ + "NapCat を起動し、Forward WebSocket サービスを有効にします。", + "WebSocket URL と任意のアクセストークンを nanobot にコピーします。", + "保存して NapCat を有効にし、QQ のテストメッセージを送信します。" + ], + "fields": { + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "ws://127.0.0.1:3001", + "help": "NapCat の Forward WebSocket URL を使います。" + }, + "accessToken": { + "label": "アクセストークン", + "placeholder": "任意のトークン" + }, + "groupPolicy": { + "label": "グループでの動作", + "choices": { + "mention": "メンションのみ", + "open": "すべてのメッセージ", + "allowlist": "許可リスト" + } + }, + "allowFrom": { + "label": "許可するユーザー", + "placeholder": "QQ ID(カンマ区切り)" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/ko.json b/nanobot/channels/napcat/webui/locales/ko.json new file mode 100644 index 00000000..70bbbc59 --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/ko.json @@ -0,0 +1,38 @@ +{ + "description": "NapCat 게이트웨이를 통해 nanobot을 연결합니다.", + "requirements": "NapCat WebSocket 엔드포인트 및 선택적 액세스 토큰", + "setup": { + "docsLabel": "NapCat 설정 가이드 열기", + "officialLabel": "NapCat 문서 열기", + "tryIt": "NapCat을 통해 QQ 테스트 메시지를 보내세요.", + "summary": "NapCat은 로컬 또는 원격 OneBot WebSocket으로 nanobot을 QQ에 연결합니다.", + "steps": [ + "NapCat을 시작하고 Forward WebSocket 서비스를 활성화하세요.", + "WebSocket URL과 선택적 액세스 토큰을 nanobot에 복사하세요.", + "저장하고 NapCat을 활성화한 다음 QQ 테스트 메시지를 보내세요." + ], + "fields": { + "wsUrl": { + "label": "WebSocket URL", + "placeholder": "ws://127.0.0.1:3001", + "help": "NapCat의 Forward WebSocket URL을 사용하세요." + }, + "accessToken": { + "label": "액세스 토큰", + "placeholder": "선택적 토큰" + }, + "groupPolicy": { + "label": "그룹 동작", + "choices": { + "mention": "멘션만", + "open": "모든 메시지", + "allowlist": "허용 목록" + } + }, + "allowFrom": { + "label": "허용된 사용자", + "placeholder": "QQ ID, 쉼표로 구분" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/pt-BR.json b/nanobot/channels/napcat/webui/locales/pt-BR.json new file mode 100644 index 00000000..99ecc278 --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/pt-BR.json @@ -0,0 +1,38 @@ +{ + "description": "Conecte o nanobot por um gateway NapCat.", + "requirements": "Endpoint WebSocket do NapCat e token de acesso opcional", + "setup": { + "docsLabel": "Abrir guia do NapCat", + "officialLabel": "Abrir documentação do NapCat", + "tryIt": "Envie uma mensagem de teste do QQ pelo NapCat.", + "summary": "O NapCat conecta o nanobot ao QQ por um endpoint WebSocket OneBot local ou remoto.", + "steps": [ + "Inicie o NapCat e ative um serviço Forward WebSocket.", + "Copie a URL WebSocket e o token opcional para o nanobot.", + "Salve e ative o NapCat; depois, envie uma mensagem de teste do QQ." + ], + "fields": { + "wsUrl": { + "label": "URL WebSocket", + "placeholder": "ws://127.0.0.1:3001", + "help": "Use a URL Forward WebSocket do NapCat." + }, + "accessToken": { + "label": "Token de acesso", + "placeholder": "Token opcional" + }, + "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 do QQ separados por vírgulas" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/vi.json b/nanobot/channels/napcat/webui/locales/vi.json new file mode 100644 index 00000000..e1a18292 --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/vi.json @@ -0,0 +1,38 @@ +{ + "description": "Kết nối nanobot qua gateway NapCat.", + "requirements": "Endpoint WebSocket NapCat và token truy cập tùy chọn", + "setup": { + "docsLabel": "Mở hướng dẫn NapCat", + "officialLabel": "Mở tài liệu NapCat", + "tryIt": "Gửi tin nhắn thử QQ qua NapCat.", + "summary": "NapCat kết nối nanobot với QQ qua endpoint OneBot WebSocket cục bộ hoặc từ xa.", + "steps": [ + "Khởi động NapCat và bật dịch vụ Forward WebSocket.", + "Sao chép URL WebSocket và token tùy chọn vào nanobot.", + "Lưu và bật NapCat, sau đó gửi tin nhắn thử QQ." + ], + "fields": { + "wsUrl": { + "label": "URL WebSocket", + "placeholder": "ws://127.0.0.1:3001", + "help": "Dùng URL Forward WebSocket từ NapCat." + }, + "accessToken": { + "label": "Token truy cập", + "placeholder": "Token tùy chọn" + }, + "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 QQ, phân tách bằng dấu phẩy" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/zh-CN.json b/nanobot/channels/napcat/webui/locales/zh-CN.json new file mode 100644 index 00000000..f9c3f7da --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/zh-CN.json @@ -0,0 +1,38 @@ +{ + "description": "通过 NapCat 网关连接 nanobot。", + "requirements": "NapCat WebSocket 地址和可选访问令牌", + "setup": { + "docsLabel": "打开 NapCat 配置指南", + "officialLabel": "打开 NapCat 文档", + "tryIt": "通过 NapCat 发送一条 QQ 测试消息。", + "summary": "NapCat 通过本地或远程 OneBot WebSocket 地址将 nanobot 连接到 QQ。", + "steps": [ + "启动 NapCat 并启用正向 WebSocket 服务。", + "将 WebSocket 地址和可选访问令牌复制到 nanobot。", + "保存并启用 NapCat,然后发送一条 QQ 测试消息。" + ], + "fields": { + "wsUrl": { + "label": "WebSocket 地址", + "placeholder": "ws://127.0.0.1:3001", + "help": "使用 NapCat 的正向 WebSocket 地址。" + }, + "accessToken": { + "label": "访问令牌", + "placeholder": "可选令牌" + }, + "groupPolicy": { + "label": "群组行为", + "choices": { + "mention": "仅提及时", + "open": "所有消息", + "allowlist": "白名单" + } + }, + "allowFrom": { + "label": "允许的用户", + "placeholder": "QQ ID,用逗号分隔" + } + } + } +} diff --git a/nanobot/channels/napcat/webui/locales/zh-TW.json b/nanobot/channels/napcat/webui/locales/zh-TW.json new file mode 100644 index 00000000..5f1bb931 --- /dev/null +++ b/nanobot/channels/napcat/webui/locales/zh-TW.json @@ -0,0 +1,38 @@ +{ + "description": "透過 NapCat 閘道連接 nanobot。", + "requirements": "NapCat WebSocket 網址和可選存取權杖", + "setup": { + "docsLabel": "開啟 NapCat 設定指南", + "officialLabel": "開啟 NapCat 文件", + "tryIt": "透過 NapCat 傳送一則 QQ 測試訊息。", + "summary": "NapCat 透過本機或遠端 OneBot WebSocket 網址將 nanobot 連接到 QQ。", + "steps": [ + "啟動 NapCat 並啟用正向 WebSocket 服務。", + "將 WebSocket 網址和可選存取權杖複製到 nanobot。", + "儲存並啟用 NapCat,然後傳送一則 QQ 測試訊息。" + ], + "fields": { + "wsUrl": { + "label": "WebSocket 網址", + "placeholder": "ws://127.0.0.1:3001", + "help": "使用 NapCat 的正向 WebSocket 網址。" + }, + "accessToken": { + "label": "存取權杖", + "placeholder": "可選權杖" + }, + "groupPolicy": { + "label": "群組行為", + "choices": { + "mention": "僅提及時", + "open": "所有訊息", + "allowlist": "允許清單" + } + }, + "allowFrom": { + "label": "允許的使用者", + "placeholder": "QQ ID,以逗號分隔" + } + } + } +} diff --git a/nanobot/channels/plugin.py b/nanobot/channels/plugin.py new file mode 100644 index 00000000..beee1c20 --- /dev/null +++ b/nanobot/channels/plugin.py @@ -0,0 +1,186 @@ +"""Typed metadata for self-contained channel packages.""" + +from __future__ import annotations + +import importlib +import re +from dataclasses import dataclass +from functools import lru_cache +from importlib.resources import files +from typing import TYPE_CHECKING, Any + +from packaging.requirements import InvalidRequirement, Requirement + +from nanobot.channels.contracts import ChannelManagementSpec, ChannelSetupSpec + +if TYPE_CHECKING: + from nanobot.channels.base import BaseChannel + +_CHANNEL_PACKAGE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*") + + +@dataclass(frozen=True) +class ChannelPlugin: + """Dependency-free manifest for one channel package. + + ``runtime`` is an absolute ``module:attribute`` target. Keeping it as an + import string lets discovery inspect metadata without importing optional + platform SDKs. + """ + + name: str + display_name: str + runtime: str + connector: str | None = None + setup: ChannelSetupSpec | None = None + management: ChannelManagementSpec = ChannelManagementSpec() + dependencies: tuple[str, ...] = () + default_enabled: bool = False + settings_visible: bool = True + capabilities: frozenset[str] = frozenset() + webui: str | None = None + + def __post_init__(self) -> None: + if _CHANNEL_PACKAGE_NAME.fullmatch(self.name) is None: + raise ValueError( + "channel plugin name must start with a letter and contain only letters, " + "digits, or underscores" + ) + _target_parts(self.runtime, label="runtime") + if self.connector is not None: + _target_parts(self.connector, label="connector") + if self.setup is not None and not isinstance(self.setup, ChannelSetupSpec): + raise TypeError("channel plugin setup must be a ChannelSetupSpec or None") + if not isinstance(self.management, ChannelManagementSpec): + raise TypeError("channel plugin management must be a ChannelManagementSpec") + if not isinstance(self.dependencies, tuple) or not all( + isinstance(requirement, str) and requirement.strip() + for requirement in self.dependencies + ): + raise TypeError("channel plugin dependencies must be a tuple of requirements") + for dependency in self.dependencies: + try: + Requirement(dependency) + except InvalidRequirement as exc: + raise ValueError( + f"channel plugin dependency is not a valid requirement: {dependency}" + ) from exc + if self.webui is not None: + webui = self.webui.replace("\\", "/") + if webui.startswith("/") or ".." in webui.split("/"): + raise ValueError("channel plugin webui entry must stay inside its package") + object.__setattr__(self, "webui", webui) + + def load_channel_class(self) -> type[BaseChannel]: + """Resolve and validate the runtime class only when the channel is needed.""" + from nanobot.channels.base import BaseChannel + + module_name, _, attr_name = self.runtime.partition(":") + module = importlib.import_module(module_name) + channel_cls: Any = getattr(module, attr_name, None) + if ( + not isinstance(channel_cls, type) + or not issubclass(channel_cls, BaseChannel) + or channel_cls is BaseChannel + ): + raise ImportError( + f"Channel plugin '{self.name}' runtime '{self.runtime}' " + "does not resolve to a BaseChannel subclass" + ) + if channel_cls.name != self.name: + raise ImportError( + f"Channel plugin '{self.name}' runtime declares name '{channel_cls.name}'" + ) + return channel_cls + + def load_connector(self) -> Any: + """Construct the optional channel-owned interactive connector.""" + if self.connector is None: + raise ImportError(f"Channel plugin '{self.name}' does not provide a connector") + module_name, attr_name = _target_parts(self.connector, label="connector") + module = importlib.import_module(module_name) + factory = getattr(module, attr_name, None) + if not callable(factory): + raise ImportError( + f"Channel plugin '{self.name}' connector '{self.connector}' is not callable" + ) + connector = factory() + if not callable(getattr(connector, "handle", None)): + raise ImportError( + f"Channel plugin '{self.name}' connector '{self.connector}' " + "does not provide handle()" + ) + return connector + + +def _target_parts(target: str, *, label: str) -> tuple[str, str]: + module_name, separator, attr_name = target.partition(":") + if not separator or not module_name or not attr_name: + raise ValueError(f"channel plugin {label} must use 'module:attribute' syntax") + if not all(part.isidentifier() for part in module_name.split(".")): + raise ValueError(f"channel plugin {label} module must be an absolute import path") + if not attr_name.isidentifier(): + raise ValueError(f"channel plugin {label} attribute must be a Python identifier") + return module_name, attr_name + + +def has_channel_package(name: str) -> bool: + """Return whether *name* owns a dependency-free package manifest.""" + if _CHANNEL_PACKAGE_NAME.fullmatch(name) is None: + return False + return files("nanobot.channels").joinpath(name, "manifest.py").is_file() + + +@lru_cache(maxsize=None) +def load_channel_package(name: str) -> ChannelPlugin | None: + """Load one package manifest without importing its runtime.""" + if not has_channel_package(name): + return None + + module_name = f"nanobot.channels.{name}.manifest" + module = importlib.import_module(module_name) + plugin = getattr(module, "PLUGIN", None) + if not isinstance(plugin, ChannelPlugin): + raise TypeError(f"{module_name}.PLUGIN must be a ChannelPlugin") + if plugin.name != name: + raise TypeError( + f"{module_name}.PLUGIN declares name '{plugin.name}', expected '{name}'" + ) + + package_name = f"nanobot.channels.{name}" + package_root = files("nanobot.channels").joinpath(name) + targets = [("runtime", plugin.runtime)] + if plugin.connector is not None: + targets.append(("connector", plugin.connector)) + for label, target in targets: + target_module, _ = _target_parts(target, label=label) + if not target_module.startswith(f"{package_name}."): + raise TypeError( + f"{module_name}.PLUGIN {label} must stay inside {package_name}: " + f"{target_module}" + ) + target_parts = target_module.removeprefix(f"{package_name}.").split(".") + target_file = package_root.joinpath( + *target_parts[:-1], + f"{target_parts[-1]}.py", + ) + target_package = package_root.joinpath(*target_parts, "__init__.py") + if not (target_file.is_file() or target_package.is_file()): + raise TypeError( + f"{module_name}.PLUGIN {label} module does not exist inside its package: " + f"{target_module}" + ) + if plugin.webui is not None: + webui_entry = files("nanobot.channels").joinpath(name, *plugin.webui.split("/")) + if not webui_entry.is_file(): + raise TypeError( + f"{module_name}.PLUGIN webui entry does not exist: {plugin.webui}" + ) + return plugin + + +__all__ = [ + "ChannelPlugin", + "has_channel_package", + "load_channel_package", +] diff --git a/nanobot/channels/qq/__init__.py b/nanobot/channels/qq/__init__.py new file mode 100644 index 00000000..b23eafc3 --- /dev/null +++ b/nanobot/channels/qq/__init__.py @@ -0,0 +1 @@ +"""QQ channel package.""" diff --git a/nanobot/channels/qq/manifest.py b/nanobot/channels/qq/manifest.py new file mode 100644 index 00000000..f5b05347 --- /dev/null +++ b/nanobot/channels/qq/manifest.py @@ -0,0 +1,28 @@ +"""QQ 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={ + "appId": field(), + "secret": field("secret"), + "allowFrom": field("list"), + "msgFormat": field("enum", choices={"plain", "markdown"}, default="plain"), + }, + required=required_fields("appId", "secret"), + official_url="https://q.qq.com/", +) + +PLUGIN = ChannelPlugin( + name="qq", + display_name="QQ", + runtime=f"{__package__}.runtime:QQChannel", + setup=SETUP_SPEC, + dependencies=( + "aiohttp>=3.9.0,<4.0.0", + "qq-botpy>=1.2.0,<2.0.0", + ), + webui="webui/index.ts", +) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq/runtime.py similarity index 100% rename from nanobot/channels/qq.py rename to nanobot/channels/qq/runtime.py diff --git a/nanobot/channels/qq/tests/__init__.py b/nanobot/channels/qq/tests/__init__.py new file mode 100644 index 00000000..5d32d82a --- /dev/null +++ b/nanobot/channels/qq/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the QQ channel package.""" diff --git a/tests/channels/test_qq_ack_message.py b/nanobot/channels/qq/tests/test_qq_ack_message.py similarity index 98% rename from tests/channels/test_qq_ack_message.py rename to nanobot/channels/qq/tests/test_qq_ack_message.py index 0f3a2dbe..cfda182b 100644 --- a/tests/channels/test_qq_ack_message.py +++ b/nanobot/channels/qq/tests/test_qq_ack_message.py @@ -23,7 +23,7 @@ if not QQ_AVAILABLE: pytest.skip("QQ dependencies not installed (qq-botpy)", allow_module_level=True) from nanobot.bus.queue import MessageBus -from nanobot.channels.qq import QQChannel, QQConfig +from nanobot.channels.qq.runtime import QQChannel, QQConfig class _FakeApi: diff --git a/tests/channels/test_qq_channel.py b/nanobot/channels/qq/tests/test_qq_channel.py similarity index 99% rename from tests/channels/test_qq_channel.py rename to nanobot/channels/qq/tests/test_qq_channel.py index 10281e06..625dfa86 100644 --- a/tests/channels/test_qq_channel.py +++ b/nanobot/channels/qq/tests/test_qq_channel.py @@ -19,7 +19,7 @@ import aiohttp from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.qq import QQChannel, QQConfig +from nanobot.channels.qq.runtime import QQChannel, QQConfig class _FakeApi: diff --git a/tests/channels/test_qq_media.py b/nanobot/channels/qq/tests/test_qq_media.py similarity index 99% rename from tests/channels/test_qq_media.py rename to nanobot/channels/qq/tests/test_qq_media.py index 7bbcf2fa..57cfc752 100644 --- a/tests/channels/test_qq_media.py +++ b/nanobot/channels/qq/tests/test_qq_media.py @@ -17,7 +17,7 @@ if not QQ_AVAILABLE: from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.qq import ( +from nanobot.channels.qq.runtime import ( QQ_FILE_TYPE_FILE, QQ_FILE_TYPE_IMAGE, QQChannel, diff --git a/nanobot/channels/qq/webui/index.ts b/nanobot/channels/qq/webui/index.ts new file mode 100644 index 00000000..8221659c --- /dev/null +++ b/nanobot/channels/qq/webui/index.ts @@ -0,0 +1,21 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "QQ", + initials: "QQ", + color: "#12B7F5", + logoUrl: "https://im.qq.com/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("qq"), + fields: [ + { key: "channels.qq.appId" }, + { key: "channels.qq.secret" }, + { key: "channels.qq.allowFrom" }, + { key: "channels.qq.msgFormat" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/qq/webui/locales/en.json b/nanobot/channels/qq/webui/locales/en.json new file mode 100644 index 00000000..1ed9e4b9 --- /dev/null +++ b/nanobot/channels/qq/webui/locales/en.json @@ -0,0 +1,38 @@ +{ + "description": "Use nanobot from QQ chats.", + "requirements": "QQ bot credentials and gateway", + "setup": { + "docsLabel": "Open QQ setup", + "officialLabel": "Open QQ bot console", + "tryIt": "Send a direct or group test message from QQ.", + "summary": "QQ uses the official bot credentials and a long WebSocket connection.", + "steps": [ + "Create a bot in QQ Open Platform and copy its App ID and Secret.", + "Choose the message format and optional sender allowlist.", + "Save and enable QQ, then send a direct or group test message." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "QQ bot app ID", + "help": "Copy it from QQ Open Platform." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Save this before leaving the QQ credentials page." + }, + "allowFrom": { + "label": "Allowed users", + "placeholder": "Open IDs, comma separated" + }, + "msgFormat": { + "label": "Message format", + "choices": { + "plain": "Plain text", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/es.json b/nanobot/channels/qq/webui/locales/es.json new file mode 100644 index 00000000..6c3a18eb --- /dev/null +++ b/nanobot/channels/qq/webui/locales/es.json @@ -0,0 +1,38 @@ +{ + "description": "Usa nanobot desde chats de QQ.", + "requirements": "Credenciales del bot de QQ y gateway", + "setup": { + "docsLabel": "Abrir guía de QQ", + "officialLabel": "Abrir consola del bot de QQ", + "tryIt": "Envía un mensaje de prueba directo o grupal desde QQ.", + "summary": "QQ usa las credenciales oficiales del bot y una conexión WebSocket persistente.", + "steps": [ + "Crea un bot en QQ Open Platform y copia su App ID y Secret.", + "Elige el formato de mensajes y la lista opcional de remitentes.", + "Guarda y activa QQ; después envía un mensaje de prueba directo o grupal." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "App ID del bot de QQ", + "help": "Cópialo de QQ Open Platform." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Guárdalo antes de salir de la página de credenciales de QQ." + }, + "allowFrom": { + "label": "Usuarios permitidos", + "placeholder": "Open ID separados por comas" + }, + "msgFormat": { + "label": "Formato de mensajes", + "choices": { + "plain": "Texto sin formato", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/fr.json b/nanobot/channels/qq/webui/locales/fr.json new file mode 100644 index 00000000..79256cb3 --- /dev/null +++ b/nanobot/channels/qq/webui/locales/fr.json @@ -0,0 +1,38 @@ +{ + "description": "Utilisez nanobot depuis les conversations QQ.", + "requirements": "Identifiants du bot QQ et passerelle", + "setup": { + "docsLabel": "Ouvrir le guide QQ", + "officialLabel": "Ouvrir la console du bot QQ", + "tryIt": "Envoyez un message test privé ou de groupe depuis QQ.", + "summary": "QQ utilise les identifiants officiels du bot et une connexion WebSocket persistante.", + "steps": [ + "Créez un bot sur QQ Open Platform et copiez son App ID et son Secret.", + "Choisissez le format des messages et la liste d’expéditeurs facultative.", + "Enregistrez et activez QQ, puis envoyez un message test privé ou de groupe." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "App ID du bot QQ", + "help": "Copiez-le depuis QQ Open Platform." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Enregistrez-le avant de quitter la page des identifiants QQ." + }, + "allowFrom": { + "label": "Utilisateurs autorisés", + "placeholder": "Open ID séparés par des virgules" + }, + "msgFormat": { + "label": "Format des messages", + "choices": { + "plain": "Texte brut", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/id.json b/nanobot/channels/qq/webui/locales/id.json new file mode 100644 index 00000000..0d8552eb --- /dev/null +++ b/nanobot/channels/qq/webui/locales/id.json @@ -0,0 +1,38 @@ +{ + "description": "Gunakan nanobot dari chat QQ.", + "requirements": "Kredensial bot QQ dan gateway", + "setup": { + "docsLabel": "Buka panduan QQ", + "officialLabel": "Buka konsol bot QQ", + "tryIt": "Kirim pesan uji langsung atau grup dari QQ.", + "summary": "QQ menggunakan kredensial bot resmi dan koneksi WebSocket persisten.", + "steps": [ + "Buat bot di QQ Open Platform dan salin App ID serta Secret.", + "Pilih format pesan dan daftar pengirim opsional.", + "Simpan dan aktifkan QQ, lalu kirim pesan uji langsung atau grup." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "App ID bot QQ", + "help": "Salin dari QQ Open Platform." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Simpan sebelum meninggalkan halaman kredensial QQ." + }, + "allowFrom": { + "label": "Pengguna yang diizinkan", + "placeholder": "Open ID, dipisahkan koma" + }, + "msgFormat": { + "label": "Format pesan", + "choices": { + "plain": "Teks biasa", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/ja.json b/nanobot/channels/qq/webui/locales/ja.json new file mode 100644 index 00000000..3bdb587c --- /dev/null +++ b/nanobot/channels/qq/webui/locales/ja.json @@ -0,0 +1,38 @@ +{ + "description": "QQ チャットから nanobot を利用します。", + "requirements": "QQ ボットの認証情報とゲートウェイ", + "setup": { + "docsLabel": "QQ 設定ガイドを開く", + "officialLabel": "QQ ボットコンソールを開く", + "tryIt": "QQ から個人またはグループのテストメッセージを送信します。", + "summary": "QQ は公式ボット認証情報と常時接続の WebSocket を使用します。", + "steps": [ + "QQ Open Platform でボットを作成し、App ID と Secret をコピーします。", + "メッセージ形式と任意の送信者許可リストを選びます。", + "保存して QQ を有効にし、個人またはグループのテストメッセージを送信します。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "QQ ボット App ID", + "help": "QQ Open Platform からコピーします。" + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "QQ の認証情報ページを離れる前に保存してください。" + }, + "allowFrom": { + "label": "許可するユーザー", + "placeholder": "Open ID(カンマ区切り)" + }, + "msgFormat": { + "label": "メッセージ形式", + "choices": { + "plain": "プレーンテキスト", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/ko.json b/nanobot/channels/qq/webui/locales/ko.json new file mode 100644 index 00000000..a21648e8 --- /dev/null +++ b/nanobot/channels/qq/webui/locales/ko.json @@ -0,0 +1,38 @@ +{ + "description": "QQ 채팅에서 nanobot을 사용합니다.", + "requirements": "QQ 봇 자격 증명 및 게이트웨이", + "setup": { + "docsLabel": "QQ 설정 가이드 열기", + "officialLabel": "QQ 봇 콘솔 열기", + "tryIt": "QQ에서 개인 또는 그룹 테스트 메시지를 보내세요.", + "summary": "QQ는 공식 봇 자격 증명과 지속적인 WebSocket 연결을 사용합니다.", + "steps": [ + "QQ Open Platform에서 봇을 만들고 App ID와 Secret을 복사하세요.", + "메시지 형식과 선택적 발신자 허용 목록을 고르세요.", + "저장하고 QQ를 활성화한 다음 개인 또는 그룹 테스트 메시지를 보내세요." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "QQ 봇 App ID", + "help": "QQ Open Platform에서 복사하세요." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "QQ 자격 증명 페이지를 떠나기 전에 저장하세요." + }, + "allowFrom": { + "label": "허용된 사용자", + "placeholder": "Open ID, 쉼표로 구분" + }, + "msgFormat": { + "label": "메시지 형식", + "choices": { + "plain": "일반 텍스트", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/pt-BR.json b/nanobot/channels/qq/webui/locales/pt-BR.json new file mode 100644 index 00000000..026509f5 --- /dev/null +++ b/nanobot/channels/qq/webui/locales/pt-BR.json @@ -0,0 +1,38 @@ +{ + "description": "Use o nanobot em conversas do QQ.", + "requirements": "Credenciais do bot QQ e gateway", + "setup": { + "docsLabel": "Abrir guia do QQ", + "officialLabel": "Abrir console do bot QQ", + "tryIt": "Envie uma mensagem de teste direta ou em grupo pelo QQ.", + "summary": "O QQ usa credenciais oficiais do bot e uma conexão WebSocket persistente.", + "steps": [ + "Crie um bot no QQ Open Platform e copie o App ID e o Secret.", + "Escolha o formato das mensagens e a lista opcional de remetentes.", + "Salve e ative o QQ; depois, envie uma mensagem de teste direta ou em grupo." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "App ID do bot QQ", + "help": "Copie do QQ Open Platform." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Salve antes de sair da página de credenciais do QQ." + }, + "allowFrom": { + "label": "Usuários permitidos", + "placeholder": "Open IDs separados por vírgulas" + }, + "msgFormat": { + "label": "Formato das mensagens", + "choices": { + "plain": "Texto simples", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/vi.json b/nanobot/channels/qq/webui/locales/vi.json new file mode 100644 index 00000000..5567666e --- /dev/null +++ b/nanobot/channels/qq/webui/locales/vi.json @@ -0,0 +1,38 @@ +{ + "description": "Sử dụng nanobot từ các cuộc trò chuyện QQ.", + "requirements": "Thông tin xác thực bot QQ và gateway", + "setup": { + "docsLabel": "Mở hướng dẫn QQ", + "officialLabel": "Mở bảng điều khiển bot QQ", + "tryIt": "Gửi tin nhắn thử riêng hoặc nhóm từ QQ.", + "summary": "QQ dùng thông tin xác thực bot chính thức và kết nối WebSocket lâu dài.", + "steps": [ + "Tạo bot trên QQ Open Platform và sao chép App ID cùng Secret.", + "Chọn định dạng tin nhắn và danh sách người gửi tùy chọn.", + "Lưu và bật QQ, sau đó gửi tin nhắn thử riêng hoặc nhóm." + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "App ID bot QQ", + "help": "Sao chép từ QQ Open Platform." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Lưu trước khi rời trang thông tin xác thực QQ." + }, + "allowFrom": { + "label": "Người dùng được phép", + "placeholder": "Open ID, phân tách bằng dấu phẩy" + }, + "msgFormat": { + "label": "Định dạng tin nhắn", + "choices": { + "plain": "Văn bản thuần", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/zh-CN.json b/nanobot/channels/qq/webui/locales/zh-CN.json new file mode 100644 index 00000000..76b49f9b --- /dev/null +++ b/nanobot/channels/qq/webui/locales/zh-CN.json @@ -0,0 +1,38 @@ +{ + "description": "在 QQ 会话中使用 nanobot。", + "requirements": "QQ 机器人凭据和网关", + "setup": { + "docsLabel": "打开 QQ 配置指南", + "officialLabel": "打开 QQ 机器人后台", + "tryIt": "从 QQ 发送一条私聊或群聊测试消息。", + "summary": "QQ 使用官方机器人凭据和 WebSocket 长连接。", + "steps": [ + "在 QQ 开放平台创建机器人并复制 App ID 和 Secret。", + "选择消息格式和可选的发送者白名单。", + "保存并启用 QQ,然后发送一条私聊或群聊测试消息。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "QQ 机器人 App ID", + "help": "从 QQ 开放平台复制。" + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "离开 QQ 凭据页面前请妥善保存。" + }, + "allowFrom": { + "label": "允许的用户", + "placeholder": "Open ID,用逗号分隔" + }, + "msgFormat": { + "label": "消息格式", + "choices": { + "plain": "纯文本", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/qq/webui/locales/zh-TW.json b/nanobot/channels/qq/webui/locales/zh-TW.json new file mode 100644 index 00000000..a1261a50 --- /dev/null +++ b/nanobot/channels/qq/webui/locales/zh-TW.json @@ -0,0 +1,38 @@ +{ + "description": "在 QQ 對話中使用 nanobot。", + "requirements": "QQ 機器人憑證和閘道", + "setup": { + "docsLabel": "開啟 QQ 設定指南", + "officialLabel": "開啟 QQ 機器人後台", + "tryIt": "從 QQ 傳送一則私聊或群聊測試訊息。", + "summary": "QQ 使用官方機器人憑證和 WebSocket 長連線。", + "steps": [ + "在 QQ 開放平台建立機器人並複製 App ID 和 Secret。", + "選擇訊息格式和可選的傳送者允許清單。", + "儲存並啟用 QQ,然後傳送一則私聊或群聊測試訊息。" + ], + "fields": { + "appId": { + "label": "App ID", + "placeholder": "QQ 機器人 App ID", + "help": "從 QQ 開放平台複製。" + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "離開 QQ 憑證頁面前請妥善儲存。" + }, + "allowFrom": { + "label": "允許的使用者", + "placeholder": "Open ID,以逗號分隔" + }, + "msgFormat": { + "label": "訊息格式", + "choices": { + "plain": "純文字", + "markdown": "Markdown" + } + } + } + } +} diff --git a/nanobot/channels/registry.py b/nanobot/channels/registry.py index 95f2d6be..4e2801ec 100644 --- a/nanobot/channels/registry.py +++ b/nanobot/channels/registry.py @@ -1,105 +1,122 @@ -"""Auto-discovery for built-in channel modules and external plugins.""" +"""Discover channel descriptors and load their runtimes lazily.""" + from __future__ import annotations -import importlib import pkgutil +from functools import cache +from importlib.metadata import entry_points from typing import TYPE_CHECKING from loguru import logger +from nanobot.channels.plugin import ( + ChannelPlugin, + has_channel_package, + load_channel_package, +) + if TYPE_CHECKING: from nanobot.channels.base import BaseChannel -_INTERNAL = frozenset({ - "base", - "manager", - "registry", -}) -DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"}) + +@cache +def _warn_legacy_channel_entry_points() -> None: + # TODO: Remove this legacy entry-point detection and warning after the migration window. + names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")}) + if not names: + return + logger.warning( + "Legacy channel entry points were detected but will not be loaded: {}. " + "The '{}' entry-point group is no longer supported; use a built-in channel or " + "migrate it into nanobot/channels//.", + ", ".join(names), + "nanobot.channels", + ) -def discover_channel_names() -> list[str]: - """Return all built-in channel module names by scanning the package (zero imports).""" - import nanobot.channels as pkg +def _channel_package_names() -> list[str]: + import nanobot.channels as package return [ name - for _, name, ispkg in pkgutil.iter_modules(pkg.__path__) - if name not in _INTERNAL and not name.startswith("_") and not ispkg + for _, name, is_package in pkgutil.iter_modules(package.__path__) + if is_package and has_channel_package(name) ] -def load_channel_class(module_name: str) -> type[BaseChannel]: - """Import *module_name* and return the first BaseChannel subclass found.""" - from nanobot.channels.base import BaseChannel as _Base - - mod = importlib.import_module(f"nanobot.channels.{module_name}") - for attr in dir(mod): - obj = getattr(mod, attr) - if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base: - return obj - raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}") - - -def discover_plugins(enabled_names: set[str] | None = None) -> dict[str, type[BaseChannel]]: - """Discover external channel plugins registered via entry_points.""" - from importlib.metadata import entry_points - - plugins: dict[str, type[BaseChannel]] = {} - for ep in entry_points(group="nanobot.channels"): - if enabled_names is not None and ep.name not in enabled_names: +def discover_plugins( + enabled_names: set[str] | None = None, +) -> dict[str, ChannelPlugin]: + """Load dependency-free descriptors from self-contained channel packages.""" + _warn_legacy_channel_entry_points() + plugins: dict[str, ChannelPlugin] = {} + for name in _channel_package_names(): + if enabled_names is not None and name not in enabled_names: continue try: - cls = ep.load() - plugins[ep.name] = cls - except Exception as e: - logger.warning("Failed to load channel plugin '{}': {}", ep.name, e) + plugin = load_channel_package(name) + if plugin is not None: + plugins[name] = plugin + except Exception as exc: + logger.warning("Failed to load channel package descriptor '{}': {}", name, exc) return plugins +def load_channel_plugin(name: str) -> ChannelPlugin: + """Load one channel package descriptor.""" + plugin = discover_plugins({name}).get(name) + if plugin is None: + raise ImportError(f"Unknown channel: {name}") + return plugin + + +def channel_default_enabled(name: str) -> bool: + """Return the activation default declared by a channel descriptor.""" + try: + return load_channel_plugin(name).default_enabled + except ImportError: + return False + + +def load_channel_class(name: str) -> type[BaseChannel]: + """Load the runtime declared by one channel descriptor.""" + return load_channel_plugin(name).load_channel_class() + + def discover_enabled( enabled_names: set[str], *, - _names: list[str] | None = None, - _include_all_external: bool = False, + _plugins: dict[str, ChannelPlugin] | None = None, warn_import_errors: bool = False, ) -> dict[str, type[BaseChannel]]: - """Return channels whose module names are in *enabled_names*. - - Uses cheap ``pkgutil.iter_modules`` to list names, then imports only - those that match — skipping the heavy third-party SDK imports of - unneeded channels. - """ - names = _names if _names is not None else discover_channel_names() + """Load runtime classes only for enabled descriptors.""" + plugins = _plugins if _plugins is not None else discover_plugins(enabled_names) result: dict[str, type[BaseChannel]] = {} - for modname in names: - if modname not in enabled_names: + for name, plugin in plugins.items(): + if name not in enabled_names: continue try: - result[modname] = load_channel_class(modname) - except ImportError as e: - message = "Enabled built-in channel '{}' is not available: {}" + result[name] = plugin.load_channel_class() + except Exception as exc: + message = "Enabled channel '{}' runtime is not available: {}" if warn_import_errors: - logger.warning(message, modname, e) + logger.warning(message, name, exc) else: - logger.debug(message, modname, e) - - external = discover_plugins(None if _include_all_external else enabled_names) - shadowed = set(external) & set(names) - if shadowed: - logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed) - if _include_all_external: - result.update({k: v for k, v in external.items() if k not in shadowed}) - else: - result.update({k: v for k, v in external.items() if k not in shadowed and k in enabled_names}) - + logger.debug(message, name, exc) return result def discover_all() -> dict[str, type[BaseChannel]]: - """Return all channels: built-in (pkgutil) merged with external (entry_points). + """Load every available channel runtime.""" + plugins = discover_plugins() + return discover_enabled(set(plugins), _plugins=plugins) - Built-in channels take priority — an external plugin cannot shadow a built-in name. - """ - names = discover_channel_names() - return discover_enabled(set(names), _names=names, _include_all_external=True) + +__all__ = [ + "channel_default_enabled", + "discover_all", + "discover_enabled", + "discover_plugins", + "load_channel_class", + "load_channel_plugin", +] diff --git a/nanobot/channels/signal/__init__.py b/nanobot/channels/signal/__init__.py new file mode 100644 index 00000000..fade3e38 --- /dev/null +++ b/nanobot/channels/signal/__init__.py @@ -0,0 +1 @@ +"""Signal channel package.""" diff --git a/nanobot/channels/signal/manifest.py b/nanobot/channels/signal/manifest.py new file mode 100644 index 00000000..3beddfd2 --- /dev/null +++ b/nanobot/channels/signal/manifest.py @@ -0,0 +1,25 @@ +"""Signal management contract.""" + +from nanobot.channels._manifest import field, required +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "phoneNumber": field(), + "daemonHost": field(default="localhost"), + "daemonPort": field("int", default=8080), + "dm.allowFrom": field("list"), + "group.allowFrom": field("list"), + }, + required=(required("phoneNumber"),), + official_url="https://github.com/bbernhard/signal-cli-rest-api", +) + +PLUGIN = ChannelPlugin( + name="signal", + display_name="Signal", + runtime=f"{__package__}.runtime:SignalChannel", + setup=SETUP_SPEC, + webui="webui/index.ts", +) diff --git a/nanobot/channels/signal.py b/nanobot/channels/signal/runtime.py similarity index 100% rename from nanobot/channels/signal.py rename to nanobot/channels/signal/runtime.py diff --git a/nanobot/channels/signal/tests/__init__.py b/nanobot/channels/signal/tests/__init__.py new file mode 100644 index 00000000..db134542 --- /dev/null +++ b/nanobot/channels/signal/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Signal channel package.""" diff --git a/tests/channels/test_signal_channel.py b/nanobot/channels/signal/tests/test_signal_channel.py similarity index 99% rename from tests/channels/test_signal_channel.py rename to nanobot/channels/signal/tests/test_signal_channel.py index 7eefbcc4..a842d2db 100644 --- a/tests/channels/test_signal_channel.py +++ b/nanobot/channels/signal/tests/test_signal_channel.py @@ -12,7 +12,7 @@ import pytest from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus -from nanobot.channels.signal import ( +from nanobot.channels.signal.runtime import ( SignalChannel, SignalConfig, SignalDMConfig, @@ -811,7 +811,7 @@ class TestHandleDataMessageDM: # subsequent message — otherwise the pairing reply loops forever. approved = {"+19995550002"} monkeypatch.setattr( - "nanobot.channels.signal.is_approved", + "nanobot.channels.signal.runtime.is_approved", lambda channel, sender_id: sender_id in approved, ) ch = _make_channel(dm_enabled=True, dm_policy="allowlist", dm_allow_from=[]) diff --git a/tests/channels/test_signal_markdown.py b/nanobot/channels/signal/tests/test_signal_markdown.py similarity index 99% rename from tests/channels/test_signal_markdown.py rename to nanobot/channels/signal/tests/test_signal_markdown.py index 37a21c6d..7cb62a28 100644 --- a/tests/channels/test_signal_markdown.py +++ b/nanobot/channels/signal/tests/test_signal_markdown.py @@ -1,6 +1,6 @@ """Unit tests for the Signal markdown → plain text + textStyle converter.""" -from nanobot.channels.signal import _markdown_to_signal, _partition_styles +from nanobot.channels.signal.runtime import _markdown_to_signal, _partition_styles from nanobot.utils.helpers import split_message diff --git a/nanobot/channels/signal/webui/index.ts b/nanobot/channels/signal/webui/index.ts new file mode 100644 index 00000000..76c9a28b --- /dev/null +++ b/nanobot/channels/signal/webui/index.ts @@ -0,0 +1,22 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "Signal", + initials: "SG", + color: "#3A76F0", + logoUrl: "https://signal.org/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("signal"), + fields: [ + { key: "channels.signal.phoneNumber" }, + { key: "channels.signal.daemonHost" }, + { key: "channels.signal.daemonPort" }, + { key: "channels.signal.dm.allowFrom" }, + { key: "channels.signal.group.allowFrom" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/signal/webui/locales/en.json b/nanobot/channels/signal/webui/locales/en.json new file mode 100644 index 00000000..a2ff85c2 --- /dev/null +++ b/nanobot/channels/signal/webui/locales/en.json @@ -0,0 +1,38 @@ +{ + "description": "Use nanobot from Signal messages.", + "requirements": "signal-cli HTTP daemon, phone number, allowlist", + "setup": { + "docsLabel": "Open Signal setup", + "officialLabel": "Open signal-cli guide", + "tryIt": "Send a Signal DM to the linked phone number.", + "summary": "Signal connects through a signal-cli HTTP daemon that is already linked to a Signal phone number.", + "steps": [ + "Install signal-cli-rest-api and link or register a Signal number.", + "Enter the daemon host, port, and linked phone number.", + "Save and enable Signal, then send a direct test message." + ], + "fields": { + "phoneNumber": { + "label": "Phone number", + "placeholder": "+1234567890", + "help": "Use the Signal number registered with signal-cli." + }, + "daemonHost": { + "label": "Daemon host", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "Daemon port", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "Allowed DMs", + "placeholder": "Phone numbers or UUIDs" + }, + "group_allowFrom": { + "label": "Allowed groups", + "placeholder": "Group IDs" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/es.json b/nanobot/channels/signal/webui/locales/es.json new file mode 100644 index 00000000..2e1f3cd2 --- /dev/null +++ b/nanobot/channels/signal/webui/locales/es.json @@ -0,0 +1,38 @@ +{ + "description": "Usa nanobot desde mensajes de Signal.", + "requirements": "Daemon HTTP signal-cli, número de teléfono y lista permitida", + "setup": { + "docsLabel": "Abrir guía de Signal", + "officialLabel": "Abrir guía de signal-cli", + "tryIt": "Envía un DM de Signal al número vinculado.", + "summary": "Signal se conecta mediante un daemon HTTP signal-cli ya vinculado a un número de Signal.", + "steps": [ + "Instala signal-cli-rest-api y vincula o registra un número de Signal.", + "Introduce el host, puerto y número vinculado.", + "Guarda y activa Signal; después envía un mensaje directo de prueba." + ], + "fields": { + "phoneNumber": { + "label": "Número de teléfono", + "placeholder": "+1234567890", + "help": "Usa el número de Signal registrado con signal-cli." + }, + "daemonHost": { + "label": "Host del daemon", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "Puerto del daemon", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "DM permitidos", + "placeholder": "Números o UUID" + }, + "group_allowFrom": { + "label": "Grupos permitidos", + "placeholder": "ID de grupo" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/fr.json b/nanobot/channels/signal/webui/locales/fr.json new file mode 100644 index 00000000..d6028832 --- /dev/null +++ b/nanobot/channels/signal/webui/locales/fr.json @@ -0,0 +1,38 @@ +{ + "description": "Utilisez nanobot depuis les messages Signal.", + "requirements": "Service HTTP signal-cli, numéro de téléphone et liste d’autorisation", + "setup": { + "docsLabel": "Ouvrir le guide Signal", + "officialLabel": "Ouvrir le guide signal-cli", + "tryIt": "Envoyez un message privé Signal au numéro associé.", + "summary": "Signal se connecte via un service HTTP signal-cli déjà associé à un numéro Signal.", + "steps": [ + "Installez signal-cli-rest-api et associez ou enregistrez un numéro Signal.", + "Saisissez l’hôte, le port et le numéro associé.", + "Enregistrez et activez Signal, puis envoyez un message privé test." + ], + "fields": { + "phoneNumber": { + "label": "Numéro de téléphone", + "placeholder": "+1234567890", + "help": "Utilisez le numéro Signal enregistré avec signal-cli." + }, + "daemonHost": { + "label": "Hôte du service", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "Port du service", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "Messages privés autorisés", + "placeholder": "Numéros ou UUID" + }, + "group_allowFrom": { + "label": "Groupes autorisés", + "placeholder": "ID de groupe" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/id.json b/nanobot/channels/signal/webui/locales/id.json new file mode 100644 index 00000000..fcc83594 --- /dev/null +++ b/nanobot/channels/signal/webui/locales/id.json @@ -0,0 +1,38 @@ +{ + "description": "Gunakan nanobot dari pesan Signal.", + "requirements": "Daemon HTTP signal-cli, nomor telepon, dan daftar izin", + "setup": { + "docsLabel": "Buka panduan Signal", + "officialLabel": "Buka panduan signal-cli", + "tryIt": "Kirim DM Signal ke nomor yang ditautkan.", + "summary": "Signal terhubung melalui daemon HTTP signal-cli yang sudah ditautkan ke nomor Signal.", + "steps": [ + "Pasang signal-cli-rest-api dan tautkan atau daftarkan nomor Signal.", + "Masukkan host daemon, port, dan nomor yang ditautkan.", + "Simpan dan aktifkan Signal, lalu kirim pesan langsung uji." + ], + "fields": { + "phoneNumber": { + "label": "Nomor telepon", + "placeholder": "+1234567890", + "help": "Gunakan nomor Signal yang terdaftar di signal-cli." + }, + "daemonHost": { + "label": "Host daemon", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "Port daemon", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "DM yang diizinkan", + "placeholder": "Nomor telepon atau UUID" + }, + "group_allowFrom": { + "label": "Grup yang diizinkan", + "placeholder": "ID grup" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/ja.json b/nanobot/channels/signal/webui/locales/ja.json new file mode 100644 index 00000000..088ab56e --- /dev/null +++ b/nanobot/channels/signal/webui/locales/ja.json @@ -0,0 +1,38 @@ +{ + "description": "Signal メッセージから nanobot を利用します。", + "requirements": "signal-cli HTTP デーモン、電話番号、許可リスト", + "setup": { + "docsLabel": "Signal 設定ガイドを開く", + "officialLabel": "signal-cli ガイドを開く", + "tryIt": "連携した電話番号に Signal の DM を送信します。", + "summary": "Signal は、Signal の電話番号に連携済みの signal-cli HTTP デーモン経由で接続します。", + "steps": [ + "signal-cli-rest-api をインストールし、Signal 番号を連携または登録します。", + "デーモンのホスト、ポート、連携済み電話番号を入力します。", + "保存して Signal を有効にし、DM でテストします。" + ], + "fields": { + "phoneNumber": { + "label": "電話番号", + "placeholder": "+1234567890", + "help": "signal-cli に登録した Signal 番号を使います。" + }, + "daemonHost": { + "label": "デーモンホスト", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "デーモンポート", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "許可する DM", + "placeholder": "電話番号または UUID" + }, + "group_allowFrom": { + "label": "許可するグループ", + "placeholder": "グループ ID" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/ko.json b/nanobot/channels/signal/webui/locales/ko.json new file mode 100644 index 00000000..8fd3796f --- /dev/null +++ b/nanobot/channels/signal/webui/locales/ko.json @@ -0,0 +1,38 @@ +{ + "description": "Signal 메시지에서 nanobot을 사용합니다.", + "requirements": "signal-cli HTTP 데몬, 전화번호 및 허용 목록", + "setup": { + "docsLabel": "Signal 설정 가이드 열기", + "officialLabel": "signal-cli 가이드 열기", + "tryIt": "연결된 전화번호로 Signal DM을 보내세요.", + "summary": "Signal은 Signal 전화번호에 연결된 signal-cli HTTP 데몬을 통해 접속합니다.", + "steps": [ + "signal-cli-rest-api를 설치하고 Signal 번호를 연결하거나 등록하세요.", + "데몬 호스트, 포트, 연결된 전화번호를 입력하세요.", + "저장하고 Signal을 활성화한 다음 DM으로 테스트하세요." + ], + "fields": { + "phoneNumber": { + "label": "전화번호", + "placeholder": "+1234567890", + "help": "signal-cli에 등록된 Signal 번호를 사용하세요." + }, + "daemonHost": { + "label": "데몬 호스트", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "데몬 포트", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "허용된 DM", + "placeholder": "전화번호 또는 UUID" + }, + "group_allowFrom": { + "label": "허용된 그룹", + "placeholder": "그룹 ID" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/pt-BR.json b/nanobot/channels/signal/webui/locales/pt-BR.json new file mode 100644 index 00000000..0eb41e5e --- /dev/null +++ b/nanobot/channels/signal/webui/locales/pt-BR.json @@ -0,0 +1,38 @@ +{ + "description": "Use o nanobot em mensagens do Signal.", + "requirements": "Daemon HTTP signal-cli, número de telefone e lista de permissão", + "setup": { + "docsLabel": "Abrir guia do Signal", + "officialLabel": "Abrir guia do signal-cli", + "tryIt": "Envie uma DM do Signal ao número vinculado.", + "summary": "O Signal conecta por um daemon HTTP signal-cli já vinculado a um número Signal.", + "steps": [ + "Instale o signal-cli-rest-api e vincule ou registre um número Signal.", + "Informe o host, a porta e o número vinculado.", + "Salve e ative o Signal; depois, envie uma mensagem direta de teste." + ], + "fields": { + "phoneNumber": { + "label": "Número de telefone", + "placeholder": "+1234567890", + "help": "Use o número Signal registrado no signal-cli." + }, + "daemonHost": { + "label": "Host do daemon", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "Porta do daemon", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "DMs permitidas", + "placeholder": "Números ou UUIDs" + }, + "group_allowFrom": { + "label": "Grupos permitidos", + "placeholder": "IDs de grupo" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/vi.json b/nanobot/channels/signal/webui/locales/vi.json new file mode 100644 index 00000000..115fb5f0 --- /dev/null +++ b/nanobot/channels/signal/webui/locales/vi.json @@ -0,0 +1,38 @@ +{ + "description": "Sử dụng nanobot từ tin nhắn Signal.", + "requirements": "Dịch vụ HTTP signal-cli, số điện thoại và danh sách cho phép", + "setup": { + "docsLabel": "Mở hướng dẫn Signal", + "officialLabel": "Mở hướng dẫn signal-cli", + "tryIt": "Gửi tin nhắn riêng Signal đến số đã liên kết.", + "summary": "Signal kết nối qua dịch vụ HTTP signal-cli đã liên kết với một số Signal.", + "steps": [ + "Cài signal-cli-rest-api và liên kết hoặc đăng ký một số Signal.", + "Nhập host, cổng và số điện thoại đã liên kết.", + "Lưu và bật Signal, sau đó gửi tin nhắn riêng thử." + ], + "fields": { + "phoneNumber": { + "label": "Số điện thoại", + "placeholder": "+1234567890", + "help": "Dùng số Signal đã đăng ký với signal-cli." + }, + "daemonHost": { + "label": "Host dịch vụ", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "Cổng dịch vụ", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "Tin nhắn riêng được phép", + "placeholder": "Số điện thoại hoặc UUID" + }, + "group_allowFrom": { + "label": "Nhóm được phép", + "placeholder": "ID nhóm" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/zh-CN.json b/nanobot/channels/signal/webui/locales/zh-CN.json new file mode 100644 index 00000000..fc88d335 --- /dev/null +++ b/nanobot/channels/signal/webui/locales/zh-CN.json @@ -0,0 +1,38 @@ +{ + "description": "通过 Signal 消息使用 nanobot。", + "requirements": "signal-cli HTTP 服务、电话号码和白名单", + "setup": { + "docsLabel": "打开 Signal 配置指南", + "officialLabel": "打开 signal-cli 指南", + "tryIt": "向已关联的电话号码发送一条 Signal 私信。", + "summary": "Signal 通过已关联 Signal 电话号码的 signal-cli HTTP 服务连接。", + "steps": [ + "安装 signal-cli-rest-api,并关联或注册一个 Signal 号码。", + "填写服务主机、端口和已关联的电话号码。", + "保存并启用 Signal,然后发送一条私信测试。" + ], + "fields": { + "phoneNumber": { + "label": "电话号码", + "placeholder": "+1234567890", + "help": "使用在 signal-cli 中注册的 Signal 号码。" + }, + "daemonHost": { + "label": "服务主机", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "服务端口", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "允许的私信", + "placeholder": "电话号码或 UUID" + }, + "group_allowFrom": { + "label": "允许的群组", + "placeholder": "群组 ID" + } + } + } +} diff --git a/nanobot/channels/signal/webui/locales/zh-TW.json b/nanobot/channels/signal/webui/locales/zh-TW.json new file mode 100644 index 00000000..845836d6 --- /dev/null +++ b/nanobot/channels/signal/webui/locales/zh-TW.json @@ -0,0 +1,38 @@ +{ + "description": "透過 Signal 訊息使用 nanobot。", + "requirements": "signal-cli HTTP 服務、電話號碼和允許清單", + "setup": { + "docsLabel": "開啟 Signal 設定指南", + "officialLabel": "開啟 signal-cli 指南", + "tryIt": "向已關聯的電話號碼傳送一則 Signal 私訊。", + "summary": "Signal 透過已關聯 Signal 電話號碼的 signal-cli HTTP 服務連線。", + "steps": [ + "安裝 signal-cli-rest-api,並關聯或註冊一個 Signal 號碼。", + "填入服務主機、連接埠和已關聯的電話號碼。", + "儲存並啟用 Signal,然後傳送一則私訊測試。" + ], + "fields": { + "phoneNumber": { + "label": "電話號碼", + "placeholder": "+1234567890", + "help": "使用在 signal-cli 中註冊的 Signal 號碼。" + }, + "daemonHost": { + "label": "服務主機", + "placeholder": "localhost" + }, + "daemonPort": { + "label": "服務連接埠", + "placeholder": "8080" + }, + "dm_allowFrom": { + "label": "允許的私訊", + "placeholder": "電話號碼或 UUID" + }, + "group_allowFrom": { + "label": "允許的群組", + "placeholder": "群組 ID" + } + } + } +} diff --git a/nanobot/channels/slack/__init__.py b/nanobot/channels/slack/__init__.py new file mode 100644 index 00000000..b6466f24 --- /dev/null +++ b/nanobot/channels/slack/__init__.py @@ -0,0 +1 @@ +"""Slack channel package.""" diff --git a/nanobot/channels/slack/manifest.py b/nanobot/channels/slack/manifest.py new file mode 100644 index 00000000..13da98ef --- /dev/null +++ b/nanobot/channels/slack/manifest.py @@ -0,0 +1,30 @@ +"""Slack management contract.""" + +from nanobot.channels._manifest import GROUP_POLICIES, field, required_fields +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin +from nanobot.channels.slack.validation import validate + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "appToken": field("secret"), + "botToken": field("secret"), + "groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"), + }, + required=required_fields("appToken", "botToken"), + official_url="https://api.slack.com/apps", + validator=validate, +) + +PLUGIN = ChannelPlugin( + name="slack", + display_name="Slack", + runtime=f"{__package__}.runtime:SlackChannel", + setup=SETUP_SPEC, + dependencies=( + "aiohttp>=3.9.0,<4.0.0", + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + ), + webui="webui/index.ts", +) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack/runtime.py similarity index 100% rename from nanobot/channels/slack.py rename to nanobot/channels/slack/runtime.py diff --git a/nanobot/channels/slack/tests/__init__.py b/nanobot/channels/slack/tests/__init__.py new file mode 100644 index 00000000..b5c0a577 --- /dev/null +++ b/nanobot/channels/slack/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Slack channel package.""" diff --git a/tests/channels/test_slack_channel.py b/nanobot/channels/slack/tests/test_slack_channel.py similarity index 99% rename from tests/channels/test_slack_channel.py rename to nanobot/channels/slack/tests/test_slack_channel.py index ba8275eb..77b87cb3 100644 --- a/tests/channels/test_slack_channel.py +++ b/nanobot/channels/slack/tests/test_slack_channel.py @@ -14,7 +14,7 @@ except ImportError: from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels.slack import SLACK_MAX_MESSAGE_LEN, SlackChannel, SlackConfig +from nanobot.channels.slack.runtime import SLACK_MAX_MESSAGE_LEN, SlackChannel, SlackConfig class _FakeAsyncWebClient: diff --git a/nanobot/channels/slack/tests/test_validation.py b/nanobot/channels/slack/tests/test_validation.py new file mode 100644 index 00000000..f7ac4478 --- /dev/null +++ b/nanobot/channels/slack/tests/test_validation.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest + +from nanobot.channels.slack import validation as slack_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_channel_does_not_write_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + config_path = tmp_path / "config.json" + config = Config.model_validate( + { + "channels": { + "slack": { + "appToken": "xapp-old", + "botToken": "xoxb-old", + "groupPolicy": "mention", + } + } + } + ) + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + monkeypatch.setattr(slack_validation, "http_post", lambda *_args, **_kwargs: {"ok": True}) + + result = validate_channel_config( + "slack", + { + "channels.slack.appToken": "", + "channels.slack.botToken": "", + }, + ) + + assert result["status"] == "connected" + saved = load_config(config_path) + assert saved.channels.slack["appToken"] == "xapp-old" + assert saved.channels.slack["botToken"] == "xoxb-old" diff --git a/nanobot/channels/slack/validation.py b/nanobot/channels/slack/validation.py new file mode 100644 index 00000000..1ee378b1 --- /dev/null +++ b/nanobot/channels/slack/validation.py @@ -0,0 +1,90 @@ +"""Slack setup validation owned by the channel package.""" + +from typing import Any + +from nanobot.channels.contracts import ChannelValidationContext +from nanobot.channels.validation import ( + check, + http_post, + message_from_response, + official_action, + payload, + required_checks, + status_from_checks, + string_value, +) + + +def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: + checks, missing = required_checks("slack", values) + app_token = string_value(values.get("appToken")) + bot_token = string_value(values.get("botToken")) + if app_token: + checks.append( + check( + "app_token_prefix", + "Socket Mode app token", + "pass" if app_token.startswith("xapp-") else "fail", + "App-level Socket Mode tokens start with xapp-.", + action_url=official_action("slack"), + ) + ) + if bot_token: + checks.append( + check( + "bot_token_prefix", + "Bot token", + "pass" if bot_token.startswith("xoxb-") else "fail", + "Bot tokens start with xoxb- after installing the Slack app.", + action_url=official_action("slack"), + ) + ) + if bot_token.startswith("xoxb-"): + try: + data = http_post( + "https://slack.com/api/auth.test", + headers={"Authorization": f"Bearer {bot_token}"}, + ) + if data.get("ok"): + identity = { + "name": data.get("user"), + "workspace": data.get("team"), + "account": data.get("user_id"), + } + checks.append( + check( + "auth_test", + "Workspace identity", + "pass", + "Slack accepted the bot token.", + ) + ) + status = "connected" if app_token.startswith("xapp-") else "configured" + return payload( + "slack", + status, + checks, + identity=identity, + missing_fields=missing, + ) + checks.append( + check( + "auth_test", + "Workspace identity", + "fail", + message_from_response(data, "Slack rejected the bot token."), + ) + ) + except Exception as exc: + checks.append( + check( + "auth_test", + "Workspace identity", + "warn", + f"Could not reach Slack now: {exc}", + ) + ) + return status_from_checks("slack", checks, missing) + + +__all__ = ["validate"] diff --git a/nanobot/channels/slack/webui/index.ts b/nanobot/channels/slack/webui/index.ts new file mode 100644 index 00000000..59bca586 --- /dev/null +++ b/nanobot/channels/slack/webui/index.ts @@ -0,0 +1,64 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +const SLACK_SOCKET_MODE_MANIFEST = `display_information: + name: nanobot +features: + app_home: + home_tab_enabled: false + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: nanobot +oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - channels:read + - chat:write + - files:read + - files:write + - groups:history + - groups:read + - im:history + - im:write + - mpim:history + - reactions:write + - users:read +settings: + event_subscriptions: + bot_events: + - app_mention + - message.channels + - message.groups + - message.im + - message.mpim + socket_mode_enabled: true + interactivity: + is_enabled: true`; + +export default { + presentation: { + displayName: "Slack", + initials: "SL", + color: "#4A154B", + logoUrl: "https://slack.com/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("slack"), + actions: [ + { + id: "slack-manifest", + copyText: SLACK_SOCKET_MODE_MANIFEST, + logoUrl: "https://slack.com/favicon.ico", + }, + ], + fields: [ + { key: "channels.slack.appToken" }, + { key: "channels.slack.botToken" }, + { key: "channels.slack.groupPolicy" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/slack/webui/locales/en.json b/nanobot/channels/slack/webui/locales/en.json new file mode 100644 index 00000000..4e616abe --- /dev/null +++ b/nanobot/channels/slack/webui/locales/en.json @@ -0,0 +1,38 @@ +{ + "description": "Use nanobot from Slack workspaces.", + "requirements": "Slack app token, bot token, workspace install", + "setup": { + "docsLabel": "Open Slack setup", + "officialLabel": "Open Slack apps", + "tryIt": "Mention the Slack app or send it a direct message.", + "summary": "Slack uses Socket Mode by default, so it needs both app-level and bot-level tokens.", + "steps": [ + "Create a Slack app from the provided manifest and enable Socket Mode.", + "Install the app to your workspace and copy the app and bot tokens.", + "Save and enable Slack, then mention the app or send it a direct message." + ], + "actions": { + "slack-manifest": "Copy manifest" + }, + "fields": { + "appToken": { + "label": "App token", + "placeholder": "xapp-...", + "help": "Create this from Slack Socket Mode." + }, + "botToken": { + "label": "Bot token", + "placeholder": "xoxb-...", + "help": "Use the bot token after installing the Slack app." + }, + "groupPolicy": { + "label": "Group behavior", + "choices": { + "mention": "Mention only", + "open": "All messages", + "allowlist": "Allowlist" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/es.json b/nanobot/channels/slack/webui/locales/es.json new file mode 100644 index 00000000..7b8a3c9d --- /dev/null +++ b/nanobot/channels/slack/webui/locales/es.json @@ -0,0 +1,38 @@ +{ + "description": "Usa nanobot desde espacios de Slack.", + "requirements": "Token de app Slack, token del bot e instalación en el espacio", + "setup": { + "docsLabel": "Abrir guía de Slack", + "officialLabel": "Abrir aplicaciones de Slack", + "tryIt": "Menciona la app de Slack o envíale un mensaje directo.", + "summary": "Slack usa Socket Mode de forma predeterminada, por lo que necesita tokens de app y de bot.", + "steps": [ + "Crea una app de Slack con el manifiesto proporcionado y activa Socket Mode.", + "Instala la app en tu espacio y copia los tokens de app y bot.", + "Guarda y activa Slack; después menciona la app o envíale un mensaje directo." + ], + "actions": { + "slack-manifest": "Copiar manifiesto" + }, + "fields": { + "appToken": { + "label": "Token de la app", + "placeholder": "xapp-...", + "help": "Créalo desde Socket Mode en Slack." + }, + "botToken": { + "label": "Token del bot", + "placeholder": "xoxb-...", + "help": "Usa el token del bot después de instalar la app." + }, + "groupPolicy": { + "label": "Comportamiento en grupos", + "choices": { + "mention": "Solo menciones", + "open": "Todos los mensajes", + "allowlist": "Lista permitida" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/fr.json b/nanobot/channels/slack/webui/locales/fr.json new file mode 100644 index 00000000..1bef9201 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/fr.json @@ -0,0 +1,38 @@ +{ + "description": "Utilisez nanobot depuis les espaces Slack.", + "requirements": "Jeton d’application Slack, jeton du bot et installation dans l’espace", + "setup": { + "docsLabel": "Ouvrir le guide Slack", + "officialLabel": "Ouvrir les applications Slack", + "tryIt": "Mentionnez l’application Slack ou envoyez-lui un message privé.", + "summary": "Slack utilise Socket Mode par défaut et nécessite les jetons d’application et de bot.", + "steps": [ + "Créez une application Slack avec le manifeste fourni et activez Socket Mode.", + "Installez l’application dans votre espace et copiez les jetons d’application et de bot.", + "Enregistrez et activez Slack, puis mentionnez l’application ou envoyez-lui un message privé." + ], + "actions": { + "slack-manifest": "Copier le manifeste" + }, + "fields": { + "appToken": { + "label": "Jeton d’application", + "placeholder": "xapp-...", + "help": "Créez-le depuis Socket Mode dans Slack." + }, + "botToken": { + "label": "Jeton du bot", + "placeholder": "xoxb-...", + "help": "Utilisez le jeton du bot après l’installation de l’application." + }, + "groupPolicy": { + "label": "Comportement en groupe", + "choices": { + "mention": "Mentions uniquement", + "open": "Tous les messages", + "allowlist": "Liste d’autorisation" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/id.json b/nanobot/channels/slack/webui/locales/id.json new file mode 100644 index 00000000..52c0a8b1 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/id.json @@ -0,0 +1,38 @@ +{ + "description": "Gunakan nanobot dari workspace Slack.", + "requirements": "Token aplikasi Slack, token bot, dan instalasi workspace", + "setup": { + "docsLabel": "Buka panduan Slack", + "officialLabel": "Buka aplikasi Slack", + "tryIt": "Sebut aplikasi Slack atau kirim pesan langsung.", + "summary": "Slack menggunakan Socket Mode secara default sehingga memerlukan token aplikasi dan token bot.", + "steps": [ + "Buat aplikasi Slack dari manifest yang disediakan dan aktifkan Socket Mode.", + "Pasang aplikasi ke workspace dan salin token aplikasi serta bot.", + "Simpan dan aktifkan Slack, lalu sebut aplikasi atau kirim DM." + ], + "actions": { + "slack-manifest": "Salin manifest" + }, + "fields": { + "appToken": { + "label": "Token aplikasi", + "placeholder": "xapp-...", + "help": "Buat dari Socket Mode Slack." + }, + "botToken": { + "label": "Token bot", + "placeholder": "xoxb-...", + "help": "Gunakan token bot setelah memasang aplikasi Slack." + }, + "groupPolicy": { + "label": "Perilaku grup", + "choices": { + "mention": "Hanya sebutan", + "open": "Semua pesan", + "allowlist": "Daftar izin" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/ja.json b/nanobot/channels/slack/webui/locales/ja.json new file mode 100644 index 00000000..3272c732 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/ja.json @@ -0,0 +1,38 @@ +{ + "description": "Slack ワークスペースから nanobot を利用します。", + "requirements": "Slack アプリトークン、ボットトークン、ワークスペースへのインストール", + "setup": { + "docsLabel": "Slack 設定ガイドを開く", + "officialLabel": "Slack アプリを開く", + "tryIt": "Slack アプリをメンションするか、DM を送信します。", + "summary": "Slack は既定で Socket Mode を使うため、アプリレベルとボットレベルの両方のトークンが必要です。", + "steps": [ + "提供されたマニフェストから Slack アプリを作成し、Socket Mode を有効にします。", + "アプリをワークスペースにインストールし、アプリとボットのトークンをコピーします。", + "保存して Slack を有効にし、アプリをメンションするか DM を送信します。" + ], + "actions": { + "slack-manifest": "マニフェストをコピー" + }, + "fields": { + "appToken": { + "label": "アプリトークン", + "placeholder": "xapp-...", + "help": "Slack の Socket Mode で作成します。" + }, + "botToken": { + "label": "ボットトークン", + "placeholder": "xoxb-...", + "help": "Slack アプリのインストール後にボットトークンを使います。" + }, + "groupPolicy": { + "label": "グループでの動作", + "choices": { + "mention": "メンションのみ", + "open": "すべてのメッセージ", + "allowlist": "許可リスト" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/ko.json b/nanobot/channels/slack/webui/locales/ko.json new file mode 100644 index 00000000..a7a90157 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/ko.json @@ -0,0 +1,38 @@ +{ + "description": "Slack 워크스페이스에서 nanobot을 사용합니다.", + "requirements": "Slack 앱 토큰, 봇 토큰 및 워크스페이스 설치", + "setup": { + "docsLabel": "Slack 설정 가이드 열기", + "officialLabel": "Slack 앱 열기", + "tryIt": "Slack 앱을 멘션하거나 DM을 보내세요.", + "summary": "Slack은 기본적으로 Socket Mode를 사용하므로 앱 수준 토큰과 봇 토큰이 모두 필요합니다.", + "steps": [ + "제공된 manifest로 Slack 앱을 만들고 Socket Mode를 활성화하세요.", + "앱을 워크스페이스에 설치하고 앱 및 봇 토큰을 복사하세요.", + "저장하고 Slack을 활성화한 다음 앱을 멘션하거나 DM을 보내세요." + ], + "actions": { + "slack-manifest": "manifest 복사" + }, + "fields": { + "appToken": { + "label": "앱 토큰", + "placeholder": "xapp-...", + "help": "Slack Socket Mode에서 생성하세요." + }, + "botToken": { + "label": "봇 토큰", + "placeholder": "xoxb-...", + "help": "Slack 앱을 설치한 뒤 봇 토큰을 사용하세요." + }, + "groupPolicy": { + "label": "그룹 동작", + "choices": { + "mention": "멘션만", + "open": "모든 메시지", + "allowlist": "허용 목록" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/pt-BR.json b/nanobot/channels/slack/webui/locales/pt-BR.json new file mode 100644 index 00000000..3ed87c44 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/pt-BR.json @@ -0,0 +1,38 @@ +{ + "description": "Use o nanobot em workspaces do Slack.", + "requirements": "Token do app Slack, token do bot e instalação no workspace", + "setup": { + "docsLabel": "Abrir guia do Slack", + "officialLabel": "Abrir aplicativos do Slack", + "tryIt": "Mencione o app Slack ou envie uma mensagem direta.", + "summary": "O Slack usa Socket Mode por padrão e precisa dos tokens do app e do bot.", + "steps": [ + "Crie um app Slack com o manifesto fornecido e ative o Socket Mode.", + "Instale o app no workspace e copie os tokens do app e do bot.", + "Salve e ative o Slack; depois, mencione o app ou envie uma DM." + ], + "actions": { + "slack-manifest": "Copiar manifesto" + }, + "fields": { + "appToken": { + "label": "Token do app", + "placeholder": "xapp-...", + "help": "Crie-o no Socket Mode do Slack." + }, + "botToken": { + "label": "Token do bot", + "placeholder": "xoxb-...", + "help": "Use o token do bot após instalar o app Slack." + }, + "groupPolicy": { + "label": "Comportamento em grupos", + "choices": { + "mention": "Somente menções", + "open": "Todas as mensagens", + "allowlist": "Lista de permissão" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/vi.json b/nanobot/channels/slack/webui/locales/vi.json new file mode 100644 index 00000000..078a3838 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/vi.json @@ -0,0 +1,38 @@ +{ + "description": "Sử dụng nanobot trong không gian Slack.", + "requirements": "Token ứng dụng Slack, token bot và cài đặt vào workspace", + "setup": { + "docsLabel": "Mở hướng dẫn Slack", + "officialLabel": "Mở ứng dụng Slack", + "tryIt": "Nhắc ứng dụng Slack hoặc gửi tin nhắn riêng.", + "summary": "Slack mặc định dùng Socket Mode nên cần cả token ứng dụng và token bot.", + "steps": [ + "Tạo ứng dụng Slack từ manifest được cung cấp và bật Socket Mode.", + "Cài ứng dụng vào workspace rồi sao chép token ứng dụng và bot.", + "Lưu và bật Slack, sau đó nhắc ứng dụng hoặc gửi tin nhắn riêng." + ], + "actions": { + "slack-manifest": "Sao chép manifest" + }, + "fields": { + "appToken": { + "label": "Token ứng dụng", + "placeholder": "xapp-...", + "help": "Tạo từ Socket Mode của Slack." + }, + "botToken": { + "label": "Token bot", + "placeholder": "xoxb-...", + "help": "Dùng token bot sau khi cài ứng dụng Slack." + }, + "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" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/zh-CN.json b/nanobot/channels/slack/webui/locales/zh-CN.json new file mode 100644 index 00000000..f80709c7 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/zh-CN.json @@ -0,0 +1,38 @@ +{ + "description": "在 Slack 工作区中使用 nanobot。", + "requirements": "Slack 应用令牌、机器人令牌和工作区安装", + "setup": { + "docsLabel": "打开 Slack 配置指南", + "officialLabel": "打开 Slack 应用后台", + "tryIt": "提及 Slack 应用,或向它发送私信。", + "summary": "Slack 默认使用 Socket Mode,因此同时需要应用级令牌和机器人令牌。", + "steps": [ + "使用提供的 manifest 创建 Slack 应用并启用 Socket Mode。", + "将应用安装到工作区,并复制应用令牌和机器人令牌。", + "保存并启用 Slack,然后提及应用或发送私信。" + ], + "actions": { + "slack-manifest": "复制 manifest" + }, + "fields": { + "appToken": { + "label": "应用令牌", + "placeholder": "xapp-...", + "help": "从 Slack Socket Mode 创建。" + }, + "botToken": { + "label": "机器人令牌", + "placeholder": "xoxb-...", + "help": "安装 Slack 应用后使用机器人令牌。" + }, + "groupPolicy": { + "label": "群组行为", + "choices": { + "mention": "仅提及时", + "open": "所有消息", + "allowlist": "白名单" + } + } + } + } +} diff --git a/nanobot/channels/slack/webui/locales/zh-TW.json b/nanobot/channels/slack/webui/locales/zh-TW.json new file mode 100644 index 00000000..2d0aa726 --- /dev/null +++ b/nanobot/channels/slack/webui/locales/zh-TW.json @@ -0,0 +1,38 @@ +{ + "description": "在 Slack 工作區中使用 nanobot。", + "requirements": "Slack 應用程式權杖、機器人權杖和工作區安裝", + "setup": { + "docsLabel": "開啟 Slack 設定指南", + "officialLabel": "開啟 Slack 應用程式後台", + "tryIt": "提及 Slack 應用程式,或向它傳送私訊。", + "summary": "Slack 預設使用 Socket Mode,因此同時需要應用程式層級權杖和機器人權杖。", + "steps": [ + "使用提供的 manifest 建立 Slack 應用程式並啟用 Socket Mode。", + "將應用程式安裝到工作區,並複製應用程式權杖和機器人權杖。", + "儲存並啟用 Slack,然後提及應用程式或傳送私訊。" + ], + "actions": { + "slack-manifest": "複製 manifest" + }, + "fields": { + "appToken": { + "label": "應用程式權杖", + "placeholder": "xapp-...", + "help": "從 Slack Socket Mode 建立。" + }, + "botToken": { + "label": "機器人權杖", + "placeholder": "xoxb-...", + "help": "安裝 Slack 應用程式後使用機器人權杖。" + }, + "groupPolicy": { + "label": "群組行為", + "choices": { + "mention": "僅提及時", + "open": "所有訊息", + "allowlist": "允許清單" + } + } + } + } +} diff --git a/nanobot/channels/telegram/__init__.py b/nanobot/channels/telegram/__init__.py new file mode 100644 index 00000000..b545d1e4 --- /dev/null +++ b/nanobot/channels/telegram/__init__.py @@ -0,0 +1 @@ +"""Telegram channel package.""" diff --git a/nanobot/channels/telegram/manifest.py b/nanobot/channels/telegram/manifest.py new file mode 100644 index 00000000..ee20f715 --- /dev/null +++ b/nanobot/channels/telegram/manifest.py @@ -0,0 +1,30 @@ +"""Telegram management contract.""" + +from nanobot.channels._manifest import GROUP_POLICIES, field, required +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin +from nanobot.channels.telegram.validation import validate + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "token": field("secret"), + "allowFrom": field("list"), + "groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"), + }, + required=(required("token"),), + official_url="https://t.me/BotFather", + validator=validate, +) + +PLUGIN = ChannelPlugin( + name="telegram", + display_name="Telegram", + runtime=f"{__package__}.runtime:TelegramChannel", + setup=SETUP_SPEC, + dependencies=( + "python-telegram-bot[socks,webhooks]>=22.6,<23.0", + "socksio>=1.0.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'", + ), + webui="webui/index.ts", +) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram/runtime.py similarity index 100% rename from nanobot/channels/telegram.py rename to nanobot/channels/telegram/runtime.py diff --git a/nanobot/channels/telegram/tests/__init__.py b/nanobot/channels/telegram/tests/__init__.py new file mode 100644 index 00000000..5b6efbba --- /dev/null +++ b/nanobot/channels/telegram/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Telegram channel package.""" diff --git a/tests/channels/test_telegram_channel.py b/nanobot/channels/telegram/tests/test_telegram_channel.py similarity index 97% rename from tests/channels/test_telegram_channel.py rename to nanobot/channels/telegram/tests/test_telegram_channel.py index 4d3cfbdf..f459664e 100644 --- a/tests/channels/test_telegram_channel.py +++ b/nanobot/channels/telegram/tests/test_telegram_channel.py @@ -14,7 +14,7 @@ except ImportError: from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus -from nanobot.channels.telegram import ( +from nanobot.channels.telegram.runtime import ( TELEGRAM_REPLY_CONTEXT_MAX_LEN, TelegramChannel, TelegramConfig, @@ -257,9 +257,9 @@ async def test_start_creates_separate_pools_with_proxy(monkeypatch) -> None: app = _FakeApp(lambda: setattr(channel, "_running", False)) builder = _FakeBuilder(app) - monkeypatch.setattr("nanobot.channels.telegram.HTTPXRequest", _FakeHTTPXRequest) + monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest) monkeypatch.setattr( - "nanobot.channels.telegram.Application", + "nanobot.channels.telegram.runtime.Application", SimpleNamespace(builder=lambda: builder), ) @@ -298,9 +298,9 @@ async def test_start_respects_custom_pool_config(monkeypatch) -> None: app = _FakeApp(lambda: setattr(channel, "_running", False)) builder = _FakeBuilder(app) - monkeypatch.setattr("nanobot.channels.telegram.HTTPXRequest", _FakeHTTPXRequest) + monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest) monkeypatch.setattr( - "nanobot.channels.telegram.Application", + "nanobot.channels.telegram.runtime.Application", SimpleNamespace(builder=lambda: builder), ) @@ -355,9 +355,9 @@ async def test_start_webhook_mode(monkeypatch) -> None: app = _FakeApp(lambda: setattr(channel, "_running", False)) builder = _FakeBuilder(app) - monkeypatch.setattr("nanobot.channels.telegram.HTTPXRequest", _FakeHTTPXRequest) + monkeypatch.setattr("nanobot.channels.telegram.runtime.HTTPXRequest", _FakeHTTPXRequest) monkeypatch.setattr( - "nanobot.channels.telegram.Application", + "nanobot.channels.telegram.runtime.Application", SimpleNamespace(builder=lambda: builder), ) @@ -428,7 +428,7 @@ async def test_send_text_retries_on_timeout() -> None: channel._app.bot.send_message = flaky_send - import nanobot.channels.telegram as tg_mod + import nanobot.channels.telegram.runtime as tg_mod orig_delay = tg_mod._SEND_RETRY_BASE_DELAY tg_mod._SEND_RETRY_BASE_DELAY = 0.01 try: @@ -456,7 +456,7 @@ async def test_send_text_gives_up_after_max_retries() -> None: channel._app.bot.send_message = always_timeout - import nanobot.channels.telegram as tg_mod + import nanobot.channels.telegram.runtime as tg_mod orig_delay = tg_mod._SEND_RETRY_BASE_DELAY tg_mod._SEND_RETRY_BASE_DELAY = 0.01 try: @@ -640,7 +640,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout( MessageBus(), ) channel._app = _FakeApp(lambda: None) - monkeypatch.setattr("nanobot.channels.telegram._SEND_RETRY_BASE_DELAY", 0) + monkeypatch.setattr("nanobot.channels.telegram.runtime._SEND_RETRY_BASE_DELAY", 0) # _call_with_retry retries TimedOut up to 3 times, so the mock will be called # multiple times – but all calls must be with parse_mode="HTML" (no plain fallback). channel._app.bot.edit_message_text = AsyncMock(side_effect=TimedOut("network timeout")) @@ -751,7 +751,7 @@ async def test_send_delta_stream_end_html_expansion_does_not_overflow() -> None: could become 4800+ chars after HTML conversion, exceeding 4096 limit. The fix converts to HTML first, THEN splits by 4096. """ - from nanobot.channels.telegram import _markdown_to_telegram_html + from nanobot.channels.telegram.runtime import _markdown_to_telegram_html channel = TelegramChannel( TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), @@ -890,7 +890,7 @@ async def test_send_delta_incremental_edit_splits_oversized_buffer() -> None: @pytest.mark.asyncio async def test_send_delta_incremental_html_expansion_does_not_overflow() -> None: """Mid-stream HTML chunks stay within Telegram's rendered payload limit.""" - from nanobot.channels.telegram import TELEGRAM_HTML_MAX_LEN + from nanobot.channels.telegram.runtime import TELEGRAM_HTML_MAX_LEN channel = TelegramChannel( TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]), @@ -1079,7 +1079,7 @@ async def test_send_remote_media_url_after_security_validation(monkeypatch) -> N MessageBus(), ) channel._app = _FakeApp(lambda: None) - monkeypatch.setattr("nanobot.channels.telegram.validate_url_target", lambda url: (True, "")) + monkeypatch.setattr("nanobot.channels.telegram.runtime.validate_url_target", lambda url: (True, "")) await channel.send( OutboundMessage( @@ -1138,7 +1138,7 @@ async def test_send_blocks_unsafe_remote_media_url(monkeypatch) -> None: ) channel._app = _FakeApp(lambda: None) monkeypatch.setattr( - "nanobot.channels.telegram.validate_url_target", + "nanobot.channels.telegram.runtime.validate_url_target", lambda url: (False, "Blocked: example.com resolves to private/internal address 127.0.0.1"), ) @@ -1360,7 +1360,7 @@ async def test_download_message_media_returns_path_when_download_succeeds( media_dir = tmp_path / "media" / "telegram" media_dir.mkdir(parents=True) monkeypatch.setattr( - "nanobot.channels.telegram.get_media_dir", + "nanobot.channels.telegram.runtime.get_media_dir", lambda channel=None: media_dir if channel else tmp_path / "media", ) @@ -1396,7 +1396,7 @@ async def test_download_message_media_uses_file_unique_id_when_available( media_dir = tmp_path / "media" / "telegram" media_dir.mkdir(parents=True) monkeypatch.setattr( - "nanobot.channels.telegram.get_media_dir", + "nanobot.channels.telegram.runtime.get_media_dir", lambda channel=None: media_dir if channel else tmp_path / "media", ) @@ -1445,7 +1445,7 @@ async def test_on_message_attaches_reply_to_media_when_available(monkeypatch, tm media_dir = tmp_path / "media" / "telegram" media_dir.mkdir(parents=True) monkeypatch.setattr( - "nanobot.channels.telegram.get_media_dir", + "nanobot.channels.telegram.runtime.get_media_dir", lambda channel=None: media_dir if channel else tmp_path / "media", ) @@ -1528,7 +1528,7 @@ async def test_on_message_reply_to_caption_and_media(monkeypatch, tmp_path) -> N media_dir = tmp_path / "media" / "telegram" media_dir.mkdir(parents=True) monkeypatch.setattr( - "nanobot.channels.telegram.get_media_dir", + "nanobot.channels.telegram.runtime.get_media_dir", lambda channel=None: media_dir if channel else tmp_path / "media", ) @@ -1845,7 +1845,7 @@ async def test_send_text_does_not_fallback_on_network_timeout() -> None: channel._app.bot.send_message = always_timeout - import nanobot.channels.telegram as tg_mod + import nanobot.channels.telegram.runtime as tg_mod orig_delay = tg_mod._SEND_RETRY_BASE_DELAY tg_mod._SEND_RETRY_BASE_DELAY = 0.01 try: @@ -1882,7 +1882,7 @@ async def test_send_text_does_not_fallback_on_network_error() -> None: channel._app.bot.send_message = always_network_error - import nanobot.channels.telegram as tg_mod + import nanobot.channels.telegram.runtime as tg_mod orig_delay = tg_mod._SEND_RETRY_BASE_DELAY tg_mod._SEND_RETRY_BASE_DELAY = 0.01 try: @@ -1922,7 +1922,7 @@ async def test_send_text_falls_back_on_bad_request() -> None: channel._app.bot.send_message = html_fails - import nanobot.channels.telegram as tg_mod + import nanobot.channels.telegram.runtime as tg_mod orig_delay = tg_mod._SEND_RETRY_BASE_DELAY tg_mod._SEND_RETRY_BASE_DELAY = 0.01 try: @@ -1957,7 +1957,7 @@ async def test_send_text_bad_request_plain_fallback_exhausted() -> None: channel._app.bot.send_message = always_bad_request - import nanobot.channels.telegram as tg_mod + import nanobot.channels.telegram.runtime as tg_mod orig_delay = tg_mod._SEND_RETRY_BASE_DELAY tg_mod._SEND_RETRY_BASE_DELAY = 0.01 try: @@ -1977,7 +1977,7 @@ async def test_send_text_bad_request_plain_fallback_exhausted() -> None: # --------------------------------------------------------------------------- def test_markdown_to_html_headers_become_bold() -> None: - from nanobot.channels.telegram import _markdown_to_telegram_html + from nanobot.channels.telegram.runtime import _markdown_to_telegram_html assert _markdown_to_telegram_html("# Title") == "Title" assert _markdown_to_telegram_html("## Subtitle") == "Subtitle" @@ -1985,7 +1985,7 @@ def test_markdown_to_html_headers_become_bold() -> None: def test_markdown_to_html_numbered_lists_preserved() -> None: - from nanobot.channels.telegram import _markdown_to_telegram_html + from nanobot.channels.telegram.runtime import _markdown_to_telegram_html text = "1. First\n2. Second\n3. Third" result = _markdown_to_telegram_html(text) @@ -1995,7 +1995,7 @@ def test_markdown_to_html_numbered_lists_preserved() -> None: def test_markdown_to_html_numbered_list_normalizes_whitespace() -> None: - from nanobot.channels.telegram import _markdown_to_telegram_html + from nanobot.channels.telegram.runtime import _markdown_to_telegram_html # Extra spaces after dot should be normalized text = "1. Lots of space\n2. Two spaces" @@ -2006,7 +2006,7 @@ def test_markdown_to_html_numbered_list_normalizes_whitespace() -> None: def test_markdown_to_html_headers_survive_html_escaping() -> None: """Headers containing special HTML chars should still render as bold.""" - from nanobot.channels.telegram import _markdown_to_telegram_html + from nanobot.channels.telegram.runtime import _markdown_to_telegram_html result = _markdown_to_telegram_html("# A < B & C > D") assert "A < B & C > D" == result @@ -2014,7 +2014,7 @@ def test_markdown_to_html_headers_survive_html_escaping() -> None: def test_markdown_to_html_mixed_formatting() -> None: """Headers, bullets, numbered lists, and bold coexist correctly.""" - from nanobot.channels.telegram import _markdown_to_telegram_html + from nanobot.channels.telegram.runtime import _markdown_to_telegram_html text = "# Overview\n\n- bullet one\n- bullet two\n\n1. step one\n2. step two\n\n**bold text**" result = _markdown_to_telegram_html(text) @@ -2029,7 +2029,7 @@ def test_markdown_to_html_mixed_formatting() -> None: # --------------------------------------------------------------------------- def test_strip_md_block_removes_inline_formatting() -> None: - from nanobot.channels.telegram import _strip_md_block + from nanobot.channels.telegram.runtime import _strip_md_block text = "**bold** and _italic_ and ~~struck~~" result = _strip_md_block(text) @@ -2037,13 +2037,13 @@ def test_strip_md_block_removes_inline_formatting() -> None: def test_strip_md_block_strips_headers() -> None: - from nanobot.channels.telegram import _strip_md_block + from nanobot.channels.telegram.runtime import _strip_md_block assert _strip_md_block("## Title\nBody") == "Title\nBody" def test_strip_md_block_converts_bullets_and_numbers() -> None: - from nanobot.channels.telegram import _strip_md_block + from nanobot.channels.telegram.runtime import _strip_md_block text = "- item a\n1. item b\n2. item c" result = _strip_md_block(text) @@ -2053,7 +2053,7 @@ def test_strip_md_block_converts_bullets_and_numbers() -> None: def test_strip_md_block_strips_links() -> None: - from nanobot.channels.telegram import _strip_md_block + from nanobot.channels.telegram.runtime import _strip_md_block assert _strip_md_block("[click here](https://example.com)") == "click here" diff --git a/nanobot/channels/telegram/tests/test_validation.py b/nanobot/channels/telegram/tests/test_validation.py new file mode 100644 index 00000000..5d822b61 --- /dev/null +++ b/nanobot/channels/telegram/tests/test_validation.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import httpx +import pytest + +from nanobot.channels.telegram import validation as telegram_validation +from nanobot.channels.validation import validate_channel_config +from nanobot.config.loader import save_config +from nanobot.config.schema import Config + + +def test_validate_telegram_bad_token_is_invalid(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) + + result = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"}) + + assert result["status"] == "invalid" + assert result["can_enable"] is False + assert result["missing_fields"] == [] + + +def test_validate_telegram_does_not_expose_saved_token_in_http_errors( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + token = "123456:abcdefghijklmnopqrstuvwxyz" + config_path = tmp_path / "config.json" + save_config( + Config.model_validate({"channels": {"telegram": {"token": token}}}), + config_path, + ) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + def raise_http_error(url: str, **_kwargs) -> dict: + request = httpx.Request("GET", url) + response = httpx.Response(401, request=request) + raise httpx.HTTPStatusError("unauthorized", request=request, response=response) + + monkeypatch.setattr(telegram_validation, "http_get", raise_http_error) + + result = validate_channel_config("telegram", {"channels.telegram.token": ""}) + + assert token not in str(result) + assert any("HTTP 401" in check.get("message", "") for check in result["checks"]) diff --git a/nanobot/channels/telegram/validation.py b/nanobot/channels/telegram/validation.py new file mode 100644 index 00000000..f6995b7c --- /dev/null +++ b/nanobot/channels/telegram/validation.py @@ -0,0 +1,84 @@ +"""Telegram setup validation owned by the channel package.""" + +import re +from typing import Any + +import httpx + +from nanobot.channels.contracts import ChannelValidationContext +from nanobot.channels.validation import ( + check, + http_get, + message_from_response, + payload, + required_checks, + status_from_checks, + string_value, +) + + +def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: + checks, missing = required_checks("telegram", values) + token = string_value(values.get("token")) + if token: + if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token): + checks.append( + check( + "token_format", + "Token format", + "fail", + "Telegram tokens look like 123456:ABC...", + ) + ) + else: + checks.append( + check("token_format", "Token format", "pass", "Looks like a BotFather token.") + ) + try: + data = http_get(f"https://api.telegram.org/bot{token}/getMe") + if data.get("ok") and isinstance(data.get("result"), dict): + bot = data["result"] + identity = { + "name": bot.get("username") or bot.get("first_name"), + "account": str(bot.get("id") or ""), + } + checks.append( + check("get_me", "Bot identity", "pass", "Telegram accepted the bot token.") + ) + return payload( + "telegram", + "connected", + checks, + identity=identity, + missing_fields=missing, + ) + checks.append( + check( + "get_me", + "Bot identity", + "fail", + message_from_response(data, "Telegram rejected the token."), + ) + ) + except httpx.HTTPStatusError as exc: + checks.append( + check( + "get_me", + "Bot identity", + "warn", + f"Telegram could not verify the token: HTTP {exc.response.status_code}.", + ) + ) + except Exception: + checks.append( + check( + "get_me", + "Bot identity", + "warn", + "Could not reach Telegram now. Try again later.", + ) + ) + return status_from_checks("telegram", checks, missing) + + +__all__ = ["validate"] diff --git a/nanobot/channels/telegram/webui/index.ts b/nanobot/channels/telegram/webui/index.ts new file mode 100644 index 00000000..5a4302ad --- /dev/null +++ b/nanobot/channels/telegram/webui/index.ts @@ -0,0 +1,20 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "Telegram", + initials: "TG", + color: "#229ED9", + logoUrl: "https://telegram.org/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("telegram"), + fields: [ + { key: "channels.telegram.token" }, + { key: "channels.telegram.allowFrom" }, + { key: "channels.telegram.groupPolicy" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/telegram/webui/locales/en.json b/nanobot/channels/telegram/webui/locales/en.json new file mode 100644 index 00000000..e968efce --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/en.json @@ -0,0 +1,35 @@ +{ + "description": "Chat with nanobot from Telegram chats.", + "requirements": "Bot token, allowed users, gateway", + "setup": { + "docsLabel": "Open Telegram setup", + "officialLabel": "Open BotFather", + "tryIt": "Send /start or a short DM to your Telegram bot.", + "summary": "Enable turns on Telegram support. Telegram still needs a BotFather token before messages can flow.", + "steps": [ + "Create a bot with BotFather and copy its token.", + "Add the token and choose who can message the bot.", + "Save and enable Telegram, then send the bot a direct message or mention it in a group." + ], + "fields": { + "token": { + "label": "Bot token", + "placeholder": "123456:ABC...", + "help": "Create it with BotFather." + }, + "allowFrom": { + "label": "Allowed users", + "placeholder": "* or Telegram user IDs", + "help": "Leave empty to use pairing codes." + }, + "groupPolicy": { + "label": "Group behavior", + "choices": { + "mention": "Mention only", + "open": "All messages", + "allowlist": "Allowlist" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/es.json b/nanobot/channels/telegram/webui/locales/es.json new file mode 100644 index 00000000..394ceefd --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/es.json @@ -0,0 +1,35 @@ +{ + "description": "Chatea con nanobot desde Telegram.", + "requirements": "Token del bot, usuarios permitidos y gateway", + "setup": { + "docsLabel": "Abrir guía de Telegram", + "officialLabel": "Abrir BotFather", + "tryIt": "Envía /start o un DM corto a tu bot de Telegram.", + "summary": "Activar habilita Telegram. Aún necesitas un token de BotFather para intercambiar mensajes.", + "steps": [ + "Crea un bot con BotFather y copia su token.", + "Añade el token y elige quién puede escribir al bot.", + "Guarda y activa Telegram; después envía un DM o menciona el bot en un grupo." + ], + "fields": { + "token": { + "label": "Token del bot", + "placeholder": "123456:ABC...", + "help": "Créalo con BotFather." + }, + "allowFrom": { + "label": "Usuarios permitidos", + "placeholder": "* o ID de usuario de Telegram", + "help": "Déjalo vacío para usar códigos de vinculación." + }, + "groupPolicy": { + "label": "Comportamiento en grupos", + "choices": { + "mention": "Solo menciones", + "open": "Todos los mensajes", + "allowlist": "Lista permitida" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/fr.json b/nanobot/channels/telegram/webui/locales/fr.json new file mode 100644 index 00000000..9913ff4c --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/fr.json @@ -0,0 +1,35 @@ +{ + "description": "Discutez avec nanobot depuis Telegram.", + "requirements": "Jeton du bot, utilisateurs autorisés et passerelle", + "setup": { + "docsLabel": "Ouvrir le guide Telegram", + "officialLabel": "Ouvrir BotFather", + "tryIt": "Envoyez /start ou un court message privé à votre bot Telegram.", + "summary": "L’activation ouvre la prise en charge de Telegram. Un jeton BotFather reste nécessaire pour échanger des messages.", + "steps": [ + "Créez un bot avec BotFather et copiez son jeton.", + "Ajoutez le jeton et choisissez qui peut contacter le bot.", + "Enregistrez et activez Telegram, puis envoyez un message privé ou mentionnez le bot dans un groupe." + ], + "fields": { + "token": { + "label": "Jeton du bot", + "placeholder": "123456:ABC...", + "help": "Créez-le avec BotFather." + }, + "allowFrom": { + "label": "Utilisateurs autorisés", + "placeholder": "* ou ID utilisateur Telegram", + "help": "Laissez vide pour utiliser les codes d’association." + }, + "groupPolicy": { + "label": "Comportement en groupe", + "choices": { + "mention": "Mentions uniquement", + "open": "Tous les messages", + "allowlist": "Liste d’autorisation" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/id.json b/nanobot/channels/telegram/webui/locales/id.json new file mode 100644 index 00000000..1dc37a25 --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/id.json @@ -0,0 +1,35 @@ +{ + "description": "Mengobrol dengan nanobot dari Telegram.", + "requirements": "Token bot, pengguna yang diizinkan, dan gateway", + "setup": { + "docsLabel": "Buka panduan Telegram", + "officialLabel": "Buka BotFather", + "tryIt": "Kirim /start atau DM singkat ke bot Telegram Anda.", + "summary": "Mengaktifkan akan menyalakan dukungan Telegram. Token BotFather tetap diperlukan untuk bertukar pesan.", + "steps": [ + "Buat bot dengan BotFather dan salin tokennya.", + "Tambahkan token dan pilih siapa yang boleh mengirim pesan ke bot.", + "Simpan dan aktifkan Telegram, lalu kirim DM atau sebut bot di grup." + ], + "fields": { + "token": { + "label": "Token bot", + "placeholder": "123456:ABC...", + "help": "Buat dengan BotFather." + }, + "allowFrom": { + "label": "Pengguna yang diizinkan", + "placeholder": "* atau ID pengguna Telegram", + "help": "Kosongkan untuk memakai kode pairing." + }, + "groupPolicy": { + "label": "Perilaku grup", + "choices": { + "mention": "Hanya sebutan", + "open": "Semua pesan", + "allowlist": "Daftar izin" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/ja.json b/nanobot/channels/telegram/webui/locales/ja.json new file mode 100644 index 00000000..fe56e052 --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/ja.json @@ -0,0 +1,35 @@ +{ + "description": "Telegram のチャットから nanobot と会話します。", + "requirements": "ボットトークン、許可するユーザー、ゲートウェイ", + "setup": { + "docsLabel": "Telegram 設定ガイドを開く", + "officialLabel": "BotFather を開く", + "tryIt": "Telegram ボットに /start または短い DM を送信します。", + "summary": "有効化すると Telegram 対応がオンになります。メッセージの送受信には BotFather トークンが必要です。", + "steps": [ + "BotFather でボットを作成し、トークンをコピーします。", + "トークンを追加し、ボットに連絡できるユーザーを選びます。", + "保存して Telegram を有効にし、DM を送るかグループでメンションします。" + ], + "fields": { + "token": { + "label": "ボットトークン", + "placeholder": "123456:ABC...", + "help": "BotFather で作成します。" + }, + "allowFrom": { + "label": "許可するユーザー", + "placeholder": "* または Telegram ユーザー ID", + "help": "ペアリングコードを使う場合は空欄にします。" + }, + "groupPolicy": { + "label": "グループでの動作", + "choices": { + "mention": "メンションのみ", + "open": "すべてのメッセージ", + "allowlist": "許可リスト" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/ko.json b/nanobot/channels/telegram/webui/locales/ko.json new file mode 100644 index 00000000..709d17ff --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/ko.json @@ -0,0 +1,35 @@ +{ + "description": "Telegram 채팅에서 nanobot과 대화합니다.", + "requirements": "봇 토큰, 허용된 사용자 및 게이트웨이", + "setup": { + "docsLabel": "Telegram 설정 가이드 열기", + "officialLabel": "BotFather 열기", + "tryIt": "Telegram 봇에 /start 또는 짧은 DM을 보내세요.", + "summary": "활성화하면 Telegram 지원이 켜집니다. 메시지를 주고받으려면 BotFather 토큰이 필요합니다.", + "steps": [ + "BotFather로 봇을 만들고 토큰을 복사하세요.", + "토큰을 추가하고 봇에 메시지를 보낼 사용자를 선택하세요.", + "저장하고 Telegram을 활성화한 다음 DM을 보내거나 그룹에서 멘션하세요." + ], + "fields": { + "token": { + "label": "봇 토큰", + "placeholder": "123456:ABC...", + "help": "BotFather에서 생성하세요." + }, + "allowFrom": { + "label": "허용된 사용자", + "placeholder": "* 또는 Telegram 사용자 ID", + "help": "페어링 코드를 사용하려면 비워 두세요." + }, + "groupPolicy": { + "label": "그룹 동작", + "choices": { + "mention": "멘션만", + "open": "모든 메시지", + "allowlist": "허용 목록" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/pt-BR.json b/nanobot/channels/telegram/webui/locales/pt-BR.json new file mode 100644 index 00000000..ef1537bc --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/pt-BR.json @@ -0,0 +1,35 @@ +{ + "description": "Converse com o nanobot pelo Telegram.", + "requirements": "Token do bot, usuários permitidos e gateway", + "setup": { + "docsLabel": "Abrir guia do Telegram", + "officialLabel": "Abrir BotFather", + "tryIt": "Envie /start ou uma DM curta ao seu bot do Telegram.", + "summary": "Ativar liga o suporte ao Telegram. Um token do BotFather ainda é necessário para trocar mensagens.", + "steps": [ + "Crie um bot com o BotFather e copie o token.", + "Adicione o token e escolha quem pode enviar mensagens ao bot.", + "Salve e ative o Telegram; depois, envie uma DM ou mencione o bot em um grupo." + ], + "fields": { + "token": { + "label": "Token do bot", + "placeholder": "123456:ABC...", + "help": "Crie-o com o BotFather." + }, + "allowFrom": { + "label": "Usuários permitidos", + "placeholder": "* ou IDs de usuário do Telegram", + "help": "Deixe vazio para usar códigos de pareamento." + }, + "groupPolicy": { + "label": "Comportamento em grupos", + "choices": { + "mention": "Somente menções", + "open": "Todas as mensagens", + "allowlist": "Lista de permissão" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/vi.json b/nanobot/channels/telegram/webui/locales/vi.json new file mode 100644 index 00000000..bb0a16bd --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/vi.json @@ -0,0 +1,35 @@ +{ + "description": "Trò chuyện với nanobot từ Telegram.", + "requirements": "Token bot, người dùng được phép và gateway", + "setup": { + "docsLabel": "Mở hướng dẫn Telegram", + "officialLabel": "Mở BotFather", + "tryIt": "Gửi /start hoặc tin nhắn riêng ngắn đến bot Telegram.", + "summary": "Bật sẽ kích hoạt hỗ trợ Telegram. Bạn vẫn cần token BotFather để trao đổi tin nhắn.", + "steps": [ + "Tạo bot bằng BotFather và sao chép token.", + "Thêm token và chọn người có thể nhắn cho bot.", + "Lưu và bật Telegram, sau đó gửi tin nhắn riêng hoặc nhắc bot trong nhóm." + ], + "fields": { + "token": { + "label": "Token bot", + "placeholder": "123456:ABC...", + "help": "Tạo bằng BotFather." + }, + "allowFrom": { + "label": "Người dùng được phép", + "placeholder": "* hoặc ID người dùng Telegram", + "help": "Để trống để dùng mã ghép nối." + }, + "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" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/zh-CN.json b/nanobot/channels/telegram/webui/locales/zh-CN.json new file mode 100644 index 00000000..8498cac0 --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/zh-CN.json @@ -0,0 +1,35 @@ +{ + "description": "通过 Telegram 会话与 nanobot 聊天。", + "requirements": "机器人令牌、允许的用户和网关", + "setup": { + "docsLabel": "打开 Telegram 配置指南", + "officialLabel": "打开 BotFather", + "tryIt": "向 Telegram 机器人发送 /start 或一条简短私信。", + "summary": "启用只会打开 Telegram 支持;收发消息前仍需填写 BotFather 令牌。", + "steps": [ + "使用 BotFather 创建机器人并复制令牌。", + "填写令牌并选择哪些用户可以向机器人发送消息。", + "保存并启用 Telegram,然后向机器人发送私信或在群组中提及它。" + ], + "fields": { + "token": { + "label": "机器人令牌", + "placeholder": "123456:ABC...", + "help": "使用 BotFather 创建。" + }, + "allowFrom": { + "label": "允许的用户", + "placeholder": "* 或 Telegram 用户 ID", + "help": "留空则使用配对码。" + }, + "groupPolicy": { + "label": "群组行为", + "choices": { + "mention": "仅提及时", + "open": "所有消息", + "allowlist": "白名单" + } + } + } + } +} diff --git a/nanobot/channels/telegram/webui/locales/zh-TW.json b/nanobot/channels/telegram/webui/locales/zh-TW.json new file mode 100644 index 00000000..bc4a1620 --- /dev/null +++ b/nanobot/channels/telegram/webui/locales/zh-TW.json @@ -0,0 +1,35 @@ +{ + "description": "透過 Telegram 對話與 nanobot 聊天。", + "requirements": "機器人權杖、允許的使用者和閘道", + "setup": { + "docsLabel": "開啟 Telegram 設定指南", + "officialLabel": "開啟 BotFather", + "tryIt": "向 Telegram 機器人傳送 /start 或一則簡短私訊。", + "summary": "啟用只會開啟 Telegram 支援;收發訊息前仍需填入 BotFather 權杖。", + "steps": [ + "使用 BotFather 建立機器人並複製權杖。", + "填入權杖並選擇哪些使用者可以向機器人傳送訊息。", + "儲存並啟用 Telegram,然後傳送私訊或在群組中提及機器人。" + ], + "fields": { + "token": { + "label": "機器人權杖", + "placeholder": "123456:ABC...", + "help": "使用 BotFather 建立。" + }, + "allowFrom": { + "label": "允許的使用者", + "placeholder": "* 或 Telegram 使用者 ID", + "help": "留空則使用配對碼。" + }, + "groupPolicy": { + "label": "群組行為", + "choices": { + "mention": "僅提及時", + "open": "所有訊息", + "allowlist": "允許清單" + } + } + } + } +} diff --git a/nanobot/channels/validation.py b/nanobot/channels/validation.py new file mode 100644 index 00000000..91b3779e --- /dev/null +++ b/nanobot/channels/validation.py @@ -0,0 +1,417 @@ +"""Best-effort channel setup validation shared by management surfaces. + +Validation is intentionally non-authoritative: it helps the UI explain whether a +channel looks ready, but it never writes config and it does not replace runtime +channel startup semantics. +""" + +from __future__ import annotations + +import re +import socket +import ssl +from datetime import UTC, datetime +from typing import Any + +import httpx + +from nanobot.channels._setup import channel_setup_spec +from nanobot.channels.contracts import ( + ChannelSetupSpec, + ChannelValidationContext, + channel_field_value, + channel_instance_config, + channel_value_present, +) +from nanobot.channels.plugin import ChannelPlugin +from nanobot.channels.registry import load_channel_plugin +from nanobot.config.loader import load_config +from nanobot.security.network import resolve_url_target + +CheckStatus = str +SetupStatus = str + +_TIMEOUT_SECONDS = 4.0 + + +def _official_action(name: str) -> str | None: + _, spec = _channel_contract(name) + return spec.official_url if spec is not None else None + + +def _channel_contract( + name: str, +) -> tuple[ChannelPlugin | None, ChannelSetupSpec | None]: + try: + plugin = load_channel_plugin(name) + except ImportError: + return None, None + return plugin, channel_setup_spec(name, plugin=plugin) + + +def validate_channel_config( + name: str, + raw_values: dict[str, Any] | None = None, + *, + instance_id: str = "default", +) -> dict[str, Any]: + """Validate a channel setup without mutating persisted config.""" + + channel = (name or "").strip() + if not channel: + return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")]) + + config = load_config() + section = getattr(config.channels, channel, None) + plugin, setup_spec = _channel_contract(channel) + values = _channel_config( + section, + plugin=plugin, + instance_id=instance_id, + ) + values = _merge_form_values(channel, values, raw_values or {}, setup_spec=setup_spec) + + if setup_spec is not None and setup_spec.validator is not None: + context = ChannelValidationContext( + allow_local_service_access=config.tools.webui_allow_local_service_access, + ) + custom_payload = setup_spec.validator(values, context) + if custom_payload is not None: + payload = dict(custom_payload) + payload.setdefault("checks", []) + payload.setdefault("missing_fields", []) + payload.setdefault("can_enable", payload.get("status") in {"configured", "connected"}) + payload.setdefault("requires_restart", True) + payload["name"] = channel + return payload + + payload = _validate_generic(channel, values) + payload["name"] = channel + return payload + + +def _validate_generic(name: str, values: dict[str, Any]) -> dict[str, Any]: + _, spec = _channel_contract(name) + checks, missing = _required_checks(name, values, setup_spec=spec) + if spec is not None: + composite_checks, composite_missing = _composite_requirement_checks(spec, values) + checks.extend(composite_checks) + missing.extend(composite_missing) + if spec is not None and spec.required: + checks.append(_check("manual_review", "Manual setup", "skipped", "This channel can be checked from saved fields, but not fully verified in-browser.")) + return _status_from_checks(name, checks, list(dict.fromkeys(missing))) + if _enabled(values): + return _payload(name, "configured", [_check("enabled", "Enabled", "pass", "This channel is enabled.")]) + return _payload(name, "unsupported", [_check("support", "WebUI setup", "skipped", "This channel is not configurable from the WebUI yet.")]) + + +def _channel_config( + section: Any, + *, + plugin: ChannelPlugin | None, + instance_id: str, +) -> dict[str, Any]: + if plugin is not None: + return channel_instance_config(plugin, section, instance_id=instance_id) + if hasattr(section, "model_dump"): + return dict(section.model_dump(mode="json", by_alias=True)) + if isinstance(section, dict): + return dict(section) + return {} + + +def _merge_form_values( + name: str, + values: dict[str, Any], + raw_values: dict[str, Any], + *, + setup_spec: ChannelSetupSpec | None = None, +) -> dict[str, Any]: + merged = dict(values) + prefix = f"channels.{name}." + spec = setup_spec + secrets = spec.secrets if spec is not None else frozenset() + for raw_key, raw_value in raw_values.items(): + if not isinstance(raw_key, str) or not raw_key: + continue + field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key + if field in secrets and not _str(raw_value): + continue + _assign(merged, field, raw_value) + return merged + + +def _required_checks( + name: str, + values: dict[str, Any], + *, + setup_spec: ChannelSetupSpec | None = None, +) -> tuple[list[dict[str, Any]], list[str]]: + checks: list[dict[str, Any]] = [] + missing: list[str] = [] + spec = setup_spec + if spec is None: + _, spec = _channel_contract(name) + for field in spec.simple_required_fields if spec is not None else (): + value = _get(values, field) + if field == "consentGranted": + if not _truthy(value): + missing.append(field) + continue + if _str(value): + checks.append(_check(f"field:{field}", _label(field), "pass", "Configured.")) + else: + missing.append(field) + checks.append(_check(f"field:{field}", _label(field), "fail", "Required.")) + return checks, missing + + +def _composite_requirement_checks( + setup_spec: ChannelSetupSpec, + values: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[str]]: + checks: list[dict[str, Any]] = [] + missing: list[str] = [] + for index, requirement in enumerate(setup_spec.required): + if requirement.simple_field is not None or requirement.is_satisfied(values): + continue + + alternatives = [ + ( + alternative, + tuple( + field + for field in alternative + if not channel_value_present(channel_field_value(values, field)) + ), + ) + for alternative in requirement.alternatives + ] + closest = min( + alternatives, + key=lambda candidate: ( + len(candidate[1]), + -(len(candidate[0]) - len(candidate[1])), + ), + default=((), ()), + )[1] + missing.extend(closest or (f"required_setup_{index}",)) + alternatives_label = " or ".join( + " + ".join(_label(field) for field in alternative) + for alternative in requirement.alternatives + ) + message = ( + f"Complete one of: {alternatives_label}." + if alternatives_label + else "Required setup is incomplete." + ) + checks.append( + _check( + f"requirement:{index}", + "Required setup", + "fail", + message, + ) + ) + return checks, missing + + +def _status_from_checks( + name: str, + checks: list[dict[str, Any]], + missing: list[str], + *, + identity: dict[str, Any] | None = None, +) -> dict[str, Any]: + if missing: + return _payload(name, "needs_setup", checks, identity=identity, missing_fields=missing, can_enable=False) + if any(check["status"] == "fail" for check in checks): + return _payload(name, "invalid", checks, identity=identity, missing_fields=missing, can_enable=False) + if any(check["status"] == "warn" for check in checks) or any(check["status"] == "skipped" for check in checks): + return _payload(name, "configured", checks, identity=identity, missing_fields=missing) + return _payload(name, "connected", checks, identity=identity, missing_fields=missing) + + +def _payload( + name: str, + status: SetupStatus, + checks: list[dict[str, Any]], + *, + identity: dict[str, Any] | None = None, + missing_fields: list[str] | None = None, + can_enable: bool | None = None, +) -> dict[str, Any]: + missing = missing_fields or [] + return { + "name": name, + "status": status, + "checks": checks, + "identity": {key: value for key, value in (identity or {}).items() if value}, + "missing_fields": missing, + "can_enable": status not in {"needs_setup", "invalid", "unsupported"} and not missing + if can_enable is None + else can_enable, + "requires_restart": False, + "checked_at": datetime.now(UTC).isoformat(), + "message": _status_message(status), + } + + +def _check( + check_id: str, + label: str, + status: CheckStatus, + message: str | None = None, + *, + action_url: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = {"id": check_id, "label": label, "status": status} + if message: + payload["message"] = message + if action_url: + payload["action_url"] = action_url + return payload + + +def _assign(values: dict[str, Any], field: str, value: Any) -> None: + target = values + parts = field.split(".") + for part in parts[:-1]: + current = target.get(part) + if not isinstance(current, dict): + current = {} + target[part] = current + target = current + target[parts[-1]] = value + + +def _get(values: dict[str, Any], field: str) -> Any: + target: Any = values + for part in field.split("."): + if not isinstance(target, dict): + return None + target = target.get(part) + return target + + +def _str(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + return str(value).strip() + + +def _int(value: Any) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + return _str(value).lower() in {"1", "true", "yes", "on", "granted"} + + +def _enabled(values: dict[str, Any]) -> bool: + return _truthy(values.get("enabled")) + + +def _label(field: str) -> str: + words = re.sub(r"([a-z])([A-Z])", r"\1 \2", field).replace(".", " ").replace("_", " ") + return words[:1].upper() + words[1:] + + +def _status_message(status: str) -> str: + return { + "connected": "Connection verified.", + "configured": "Configuration is present, but full verification was not possible.", + "needs_setup": "Required setup is missing.", + "invalid": "Configuration was checked and looks invalid.", + "unsupported": "This channel is not supported by the WebUI setup checker.", + }.get(status, "Channel checked.") + + +def _message_from_response(data: dict[str, Any], fallback: str) -> str: + error = data.get("error") or data.get("description") or data.get("message") + return str(error) if error else fallback + + +def _http_get(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]: + with httpx.Client(timeout=_TIMEOUT_SECONDS) as client: + response = client.get(url, headers=headers) + response.raise_for_status() + data = response.json() + return data if isinstance(data, dict) else {} + + +def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]: + with httpx.Client(timeout=_TIMEOUT_SECONDS) as client: + response = client.post(url, headers=headers) + response.raise_for_status() + data = response.json() + return data if isinstance(data, dict) else {} + + +def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None: + url_host = host if ":" not in host or host.startswith("[") else f"[{host}]" + ok, error, resolved_ips = resolve_url_target( + f"http://{url_host}:{port}/", + allow_loopback=allow_loopback, + ) + if not ok: + raise ValueError(error) + + context = ssl.create_default_context() + last_error: OSError | None = None + for target_ip in resolved_ips: + try: + with socket.create_connection((target_ip, port), timeout=_TIMEOUT_SECONDS) as sock: + if port in {465, 993, 995}: + with context.wrap_socket(sock, server_hostname=host.strip("[]")): + return + return + except OSError as exc: + last_error = exc + if last_error is not None: + raise last_error + raise OSError(f"Could not resolve {host}") + + +# Public helpers for channel-owned validators. Keeping response shaping here lets +# each package own its platform checks without depending on WebUI implementation +# modules. +check = _check +enabled = _enabled +http_get = _http_get +http_post = _http_post +int_value = _int +message_from_response = _message_from_response +official_action = _official_action +payload = _payload +probe_tcp = _probe_tcp +required_checks = _required_checks +status_from_checks = _status_from_checks +string_value = _str +truthy = _truthy + +__all__ = [ + "check", + "enabled", + "http_get", + "http_post", + "int_value", + "message_from_response", + "official_action", + "payload", + "probe_tcp", + "required_checks", + "status_from_checks", + "string_value", + "truthy", + "validate_channel_config", +] diff --git a/nanobot/channels/websocket/__init__.py b/nanobot/channels/websocket/__init__.py new file mode 100644 index 00000000..328e5ae2 --- /dev/null +++ b/nanobot/channels/websocket/__init__.py @@ -0,0 +1 @@ +"""WebSocket channel package.""" diff --git a/nanobot/channels/websocket/manifest.py b/nanobot/channels/websocket/manifest.py new file mode 100644 index 00000000..c4523343 --- /dev/null +++ b/nanobot/channels/websocket/manifest.py @@ -0,0 +1,21 @@ +"""WebSocket management contract.""" + +from nanobot.channels.contracts import ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin +from nanobot.channels.websocket.validation import validate + +SETUP_SPEC = ChannelSetupSpec( + fields={}, + official_url="http://127.0.0.1:8765", + validator=validate, +) + +PLUGIN = ChannelPlugin( + name="websocket", + display_name="WebSocket", + runtime=f"{__package__}.runtime:WebSocketChannel", + setup=SETUP_SPEC, + default_enabled=True, + capabilities=frozenset({"always_enabled"}), + webui="webui/index.ts", +) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket/runtime.py similarity index 100% rename from nanobot/channels/websocket.py rename to nanobot/channels/websocket/runtime.py diff --git a/nanobot/channels/websocket/tests/__init__.py b/nanobot/channels/websocket/tests/__init__.py new file mode 100644 index 00000000..7b68d2ec --- /dev/null +++ b/nanobot/channels/websocket/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the WebSocket channel package.""" diff --git a/tests/channels/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py similarity index 99% rename from tests/channels/test_websocket_channel.py rename to nanobot/channels/websocket/tests/test_websocket_channel.py index 7ae7cb03..7557cee3 100644 --- a/tests/channels/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -11,7 +11,6 @@ import pytest import websockets from websockets.exceptions import ConnectionClosed from websockets.frames import Close -from ws_test_client import http_get as _http_get from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage from nanobot.bus.outbound_events import ( @@ -23,7 +22,7 @@ from nanobot.bus.outbound_events import ( TurnEndEvent, ) from nanobot.bus.queue import MessageBus -from nanobot.channels.websocket import ( +from nanobot.channels.websocket.runtime import ( WebSocketChannel, WebSocketConfig, _is_valid_chat_id, @@ -51,6 +50,8 @@ from nanobot.webui.http_utils import ( from nanobot.webui.settings_api import settings_payload, update_provider_settings from nanobot.webui.transcript import append_transcript_object, read_transcript_lines +from .ws_test_client import http_get as _http_get + # -- Shared helpers (aligned with test_websocket_integration.py) --------------- _PORT = 29876 @@ -134,7 +135,7 @@ async def test_start_extends_http_open_timeout_for_slow_settings_routes( bus, monkeypatch, ) -> None: - import nanobot.channels.websocket as websocket_module + import nanobot.channels.websocket.runtime as websocket_module channel = _ch(bus, port=0) seen: dict[str, Any] = {} diff --git a/tests/channels/test_websocket_envelope_media.py b/nanobot/channels/websocket/tests/test_websocket_envelope_media.py similarity index 99% rename from tests/channels/test_websocket_envelope_media.py rename to nanobot/channels/websocket/tests/test_websocket_envelope_media.py index 56829e56..94dd2511 100644 --- a/tests/channels/test_websocket_envelope_media.py +++ b/nanobot/channels/websocket/tests/test_websocket_envelope_media.py @@ -15,7 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from nanobot.channels.websocket import ( +from nanobot.channels.websocket.runtime import ( WebSocketChannel, WebSocketConfig, ) @@ -64,7 +64,7 @@ def _make_channel() -> WebSocketChannel: def test_max_message_bytes_default_supports_multi_image_frame() -> None: """Default 36 MB must comfortably hold 4 × 6 MB base64-encoded images.""" - from nanobot.channels.websocket import WebSocketConfig + from nanobot.channels.websocket.runtime import WebSocketConfig default = WebSocketConfig().max_message_bytes # 4 images × 6 MB × 1.37 base64 overhead ≈ 33 MB diff --git a/tests/channels/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py similarity index 94% rename from tests/channels/test_websocket_http_routes.py rename to nanobot/channels/websocket/tests/test_websocket_http_routes.py index b67fb8fc..d6b79508 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -12,12 +12,10 @@ from unittest.mock import AsyncMock, MagicMock from urllib.parse import quote, urlencode import pytest -from ws_test_client import InProcessHttpChannel -from ws_test_client import http_get as _http_get from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel -from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig +from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig from nanobot.cron.service import CronService from nanobot.cron.types import CronJob, CronPayload, CronSchedule from nanobot.optional_features import InstallResult @@ -31,6 +29,9 @@ from nanobot.session.manager import Session, SessionManager from nanobot.triggers.local_store import LocalTriggerStore from nanobot.webui.gateway_services import GatewayServices, build_gateway_services +from .ws_test_client import InProcessHttpChannel +from .ws_test_client import http_get as _http_get + _PORT = 29900 @@ -77,6 +78,7 @@ def _make_handler( cron_pending_job_ids: Any | None = None, local_trigger_pending_ids: Any | None = None, channel_feature_action: Any | None = None, + channel_runtime_status: Any | None = None, ) -> GatewayServices: config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg workspace = workspace_path or Path.cwd() @@ -95,6 +97,7 @@ def _make_handler( cron_pending_job_ids=cron_pending_job_ids, local_trigger_pending_ids=local_trigger_pending_ids, channel_feature_action=channel_feature_action, + channel_runtime_status=channel_runtime_status, ) @@ -111,6 +114,7 @@ def _ch( cron_pending_job_ids: Any | None = None, local_trigger_pending_ids: Any | None = None, channel_feature_action: Any | None = None, + channel_runtime_status: Any | None = None, **extra: Any, ) -> WebSocketChannel: cfg: dict[str, Any] = { @@ -133,6 +137,7 @@ def _ch( cron_pending_job_ids=cron_pending_job_ids, local_trigger_pending_ids=local_trigger_pending_ids, channel_feature_action=channel_feature_action, + channel_runtime_status=channel_runtime_status, ) return InProcessHttpChannel(cfg, bus, gateway=gateway) @@ -171,13 +176,29 @@ def _stub_matrix_feature( install_calls: list[str] | None = None, channels: list[str] | None = None, ) -> None: + from nanobot.channels.plugin import ChannelPlugin, load_channel_package + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + requested = channels or ["matrix"] + matrix = ChannelPlugin( + name="matrix", + display_name="Matrix", + runtime=f"{__name__}:_MatrixChannel", + dependencies=("matrix-nio>=0.25.2",), + ) + plugins = {"matrix": matrix} + if "websocket" in requested: + websocket = load_channel_package("websocket") + assert websocket is not None + plugins["websocket"] = websocket monkeypatch.setattr( - "nanobot.channels.registry.discover_channel_names", - lambda: channels or ["matrix"], + "nanobot.channels.registry.discover_plugins", + lambda enabled_names=None: { + name: plugin + for name, plugin in plugins.items() + if enabled_names is None or name in enabled_names + }, ) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) - monkeypatch.setattr("nanobot.channels.registry.load_channel_class", lambda _name: _MatrixChannel) monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"matrix": deps if deps is not None else []}, @@ -656,6 +677,60 @@ async def test_nanobot_feature_routes_require_token_and_enable( await server_task +@pytest.mark.asyncio +async def test_nanobot_feature_route_reports_live_channel_failure( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"channels": {"matrix": {"enabled": True}}}), + encoding="utf-8", + ) + _stub_matrix_feature(monkeypatch, config_path, channels=["matrix", "websocket"]) + channel = _ch( + bus, + session_manager=_seed_session(tmp_path), + port=29946, + channel_runtime_status=lambda: { + "websocket": { + "owner": "websocket", + "instance_id": "default", + "state": "running", + "running": True, + }, + "matrix": { + "owner": "matrix", + "instance_id": "default", + "state": "failed", + "running": False, + "error": "Channel failed to start. Check gateway logs.", + }, + }, + ) + server_task = asyncio.create_task(channel.start()) + try: + token = channel.gateway.tokens.issue_api_token(300) + response = await _http_get( + "http://127.0.0.1:29946/api/settings/nanobot-features", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + body = response.json() + matrix = next(feature for feature in body["features"] if feature["name"] == "matrix") + assert matrix["enabled"] is True + assert matrix["running"] is False + assert matrix["ready"] is False + assert matrix["runtime_status"] == "failed" + assert matrix["runtime_error"] == "Channel failed to start. Check gateway logs." + assert body["enabled_count"] == 1 + finally: + await channel.stop() + await server_task + + @pytest.mark.asyncio async def test_pairing_routes_require_token_and_approve_or_deny( bus: MagicMock, @@ -869,10 +944,14 @@ async def test_nanobot_feature_channel_action_can_apply_without_restart( ) -> None: config_path = tmp_path / "config.json" _stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"]) - calls: list[tuple[str, str]] = [] + calls: list[tuple[str, str, str | None]] = [] - async def channel_feature_action(action: str, name: str) -> dict[str, Any]: - calls.append((action, name)) + async def channel_feature_action( + action: str, + name: str, + instance_id: str | None, + ) -> dict[str, Any]: + calls.append((action, name, instance_id)) return { "handled": True, "ok": True, @@ -901,20 +980,55 @@ async def test_nanobot_feature_channel_action_can_apply_without_restart( assert response is not None assert response.status_code == 200 body = json.loads(response.body.decode()) - assert calls == [("enable", "matrix")] + assert calls == [("enable", "matrix", None)] assert body["requires_restart"] is False assert body["restart_required_sections"] == [] assert body["last_action"]["hot_reload"] is True assert body["last_action"]["message"].endswith("Matrix channel applied without restart.") +@pytest.mark.asyncio +async def test_channel_connect_runtime_import_error_is_not_reported_as_unsupported( + bus: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class BrokenConnector: + async def handle(self, _action: str, _query: dict[str, list[str]]) -> dict[str, Any]: + raise ImportError("missing optional sdk") + + class FakePlugin: + @staticmethod + def load_connector() -> BrokenConnector: + return BrokenConnector() + + monkeypatch.setattr( + "nanobot.webui.settings_routes.load_channel_plugin", + lambda _name: FakePlugin(), + ) + channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) + token = channel.gateway.tokens.issue_api_token(300) + response = await channel.gateway.http.settings_routes.dispatch( + _LOCAL, + _FakeReq( + {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}, + path="/api/settings/channels/fake/connect/start", + ), + "/api/settings/channels/fake/connect/start", + ) + + assert response is not None + assert response.status_code == 500 + assert "failed to start fake connection" in response.body.decode() + + @pytest.mark.asyncio async def test_feishu_connect_routes_write_config_and_hot_reload( bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - from nanobot.channels import feishu as feishu_module + from nanobot.channels.feishu import runtime as feishu_module from nanobot.config import loader from nanobot.config.schema import Config @@ -970,10 +1084,10 @@ async def test_feishu_connect_routes_write_config_and_hot_reload( "last_action": {"ok": True, "message": "Enabled channel 'feishu'", "enabled": True}, }, ) - calls: list[tuple[str, str]] = [] + calls: list[tuple[str, str, str]] = [] - async def channel_feature_action(action: str, name: str) -> dict[str, Any]: - calls.append((action, name)) + async def channel_feature_action(action: str, name: str, instance_id: str) -> dict[str, Any]: + calls.append((action, name, instance_id)) return { "handled": True, "ok": True, @@ -1021,7 +1135,7 @@ async def test_feishu_connect_routes_write_config_and_hot_reload( assert body["status"] == "succeeded" assert body["instance_id"] == "default" assert "app_secret" not in body - assert calls == [("enable", "feishu")] + assert calls == [("enable", "feishu", "default")] assert body["nanobot_features"]["requires_restart"] is False data = json.loads(config_path.read_text(encoding="utf-8")) assert data["channels"]["feishu"]["instances"][0]["id"] == "default" @@ -1036,9 +1150,9 @@ def test_feishu_connect_create_appends_instance( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - from nanobot.channels import feishu as feishu_module + from nanobot.channels.feishu import runtime as feishu_module + from nanobot.channels.feishu.connect import FeishuConnectStore from nanobot.config import loader - from nanobot.webui.channel_connect import FeishuConnectStore config_path = tmp_path / "config.json" config_path.write_text( @@ -1104,6 +1218,15 @@ def test_feishu_connect_create_appends_instance( assert instances[1]["displayName"] == "Assistant cli_new" assert instances[1]["avatarUrl"] == "https://example.com/cli_new.png" + duplicate_started = store.start(mode="create") + duplicate_polled = store.poll(duplicate_started["session_id"]) + duplicate_instances = json.loads(config_path.read_text(encoding="utf-8"))[ + "channels" + ]["feishu"]["instances"] + + assert duplicate_polled["instance_id"] == polled["instance_id"] + assert len(duplicate_instances) == 2 + @pytest.mark.asyncio async def test_channel_configure_route_saves_discord_config_and_hot_reloads( @@ -1125,7 +1248,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads( allow_install: bool = True, ) -> dict[str, Any]: assert action == "enable" - assert query == {"name": ["discord"]} + assert query == {"name": ["discord"], "instance_id": ["default"]} cfg = loader.load_config() section = dict(getattr(cfg.channels, "discord", {}) or {}) section["enabled"] = True @@ -1149,10 +1272,10 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads( } monkeypatch.setattr("nanobot.webui.settings_routes.nanobot_features_action", fake_feature_action) - calls: list[tuple[str, str]] = [] + calls: list[tuple[str, str, str]] = [] - async def channel_feature_action(action: str, name: str) -> dict[str, Any]: - calls.append((action, name)) + async def channel_feature_action(action: str, name: str, instance_id: str) -> dict[str, Any]: + calls.append((action, name, instance_id)) cfg = loader.load_config() assert getattr(cfg.channels, "discord")["token"] == "discord-token" return { @@ -1194,7 +1317,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads( assert body["saved"] is True assert body["name"] == "discord" assert "discord-token" not in response.body.decode() - assert calls == [("enable", "discord")] + assert calls == [("enable", "discord", "default")] assert body["nanobot_features"]["requires_restart"] is False data = json.loads(config_path.read_text(encoding="utf-8")) assert data["channels"]["discord"] == { @@ -1255,6 +1378,13 @@ async def test_channel_configure_route_preserves_existing_channel_values( assert response.status_code == 200 body = json.loads(response.body.decode()) assert body["saved_keys"] == ["channels.discord.allowChannels"] + discord = next( + feature + for feature in body["nanobot_features"]["features"] + if feature["name"] == "discord" + ) + assert discord["configured"] is True + assert discord["config_values"]["channels.discord.allowChannels"] == "new-channel" data = json.loads(config_path.read_text(encoding="utf-8")) assert data["channels"]["discord"] == { "enabled": True, diff --git a/tests/channels/test_websocket_integration.py b/nanobot/channels/websocket/tests/test_websocket_integration.py similarity index 99% rename from tests/channels/test_websocket_integration.py rename to nanobot/channels/websocket/tests/test_websocket_integration.py index f4c06069..e8f4d10c 100644 --- a/tests/channels/test_websocket_integration.py +++ b/nanobot/channels/websocket/tests/test_websocket_integration.py @@ -13,13 +13,14 @@ from unittest.mock import AsyncMock, MagicMock import pytest import websockets -from ws_test_client import WsTestClient, issue_token, issue_token_ok from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ProgressEvent -from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig +from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig from nanobot.webui.gateway_services import build_gateway_services +from .ws_test_client import WsTestClient, issue_token, issue_token_ok + def _ch(bus: Any, port: int, **kw: Any) -> WebSocketChannel: cfg: dict[str, Any] = { diff --git a/tests/channels/test_websocket_media_route.py b/nanobot/channels/websocket/tests/test_websocket_media_route.py similarity index 99% rename from tests/channels/test_websocket_media_route.py rename to nanobot/channels/websocket/tests/test_websocket_media_route.py index 937874ba..dc289654 100644 --- a/tests/channels/test_websocket_media_route.py +++ b/nanobot/channels/websocket/tests/test_websocket_media_route.py @@ -18,10 +18,8 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from ws_test_client import InProcessHttpChannel -from ws_test_client import http_get as _http_get -from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig +from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig from nanobot.session.manager import Session, SessionManager from nanobot.webui.gateway_services import build_gateway_services from nanobot.webui.media_api import ( @@ -29,6 +27,9 @@ from nanobot.webui.media_api import ( b64url_encode, ) +from .ws_test_client import InProcessHttpChannel +from .ws_test_client import http_get as _http_get + # PNG magic bytes + a couple of sentinel bytes so we can verify byte-for-byte # round-trip of the served payload. Stays under mimetype + size limits. _PNG_BYTES = ( diff --git a/tests/channels/test_websocket_protocol_boundaries.py b/nanobot/channels/websocket/tests/test_websocket_protocol_boundaries.py similarity index 96% rename from tests/channels/test_websocket_protocol_boundaries.py rename to nanobot/channels/websocket/tests/test_websocket_protocol_boundaries.py index b993ba06..89be3b4b 100644 --- a/tests/channels/test_websocket_protocol_boundaries.py +++ b/nanobot/channels/websocket/tests/test_websocket_protocol_boundaries.py @@ -4,7 +4,7 @@ from __future__ import annotations import pytest -from nanobot.channels.websocket import ( +from nanobot.channels.websocket.runtime import ( _is_valid_chat_id, _parse_envelope, ) diff --git a/tests/channels/test_websocket_reconnect_idle.py b/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py similarity index 87% rename from tests/channels/test_websocket_reconnect_idle.py rename to nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py index 8c613ab0..bcdd41d0 100644 --- a/tests/channels/test_websocket_reconnect_idle.py +++ b/nanobot/channels/websocket/tests/test_websocket_reconnect_idle.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest -from nanobot.channels.websocket import WebSocketChannel +from nanobot.channels.websocket.runtime import WebSocketChannel @pytest.mark.asyncio @@ -26,7 +26,7 @@ async def test_hydrate_after_subscribe_is_quiet_when_no_turn_active(): channel.send_goal_state = mock_send_goal_state channel.send_goal_status = mock_send_goal_status - with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=None): + with patch("nanobot.channels.websocket.runtime.websocket_turn_wall_started_at", return_value=None): await channel._hydrate_after_subscribe("test-chat") assert sent_events == [] @@ -51,7 +51,7 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active(): channel.send_goal_state = mock_send_goal_state channel.send_goal_status = mock_send_goal_status - with patch("nanobot.channels.websocket.websocket_turn_wall_started_at", return_value=1234567890.0): + with patch("nanobot.channels.websocket.runtime.websocket_turn_wall_started_at", return_value=1234567890.0): await channel._hydrate_after_subscribe("test-chat") running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"] diff --git a/tests/channels/ws_test_client.py b/nanobot/channels/websocket/tests/ws_test_client.py similarity index 98% rename from tests/channels/ws_test_client.py rename to nanobot/channels/websocket/tests/ws_test_client.py index e05452b4..ecdbbf0a 100644 --- a/tests/channels/ws_test_client.py +++ b/nanobot/channels/websocket/tests/ws_test_client.py @@ -3,7 +3,7 @@ Provides an async ``WsTestClient`` class and token-issuance helpers that integration tests can import and use directly:: - from ws_test_client import WsTestClient + from nanobot.channels.websocket.tests.ws_test_client import WsTestClient async with WsTestClient("ws://127.0.0.1:8765/", client_id="t") as c: ready = await c.recv_ready() @@ -24,7 +24,7 @@ from websockets.asyncio.client import ClientConnection from websockets.datastructures import Headers from websockets.http11 import Request as WsRequest -from nanobot.channels.websocket import WebSocketChannel +from nanobot.channels.websocket.runtime import WebSocketChannel from nanobot.webui.http_utils import http_response _IN_PROCESS_HTTP_CHANNELS: dict[int, InProcessHttpChannel] = {} diff --git a/nanobot/channels/websocket/validation.py b/nanobot/channels/websocket/validation.py new file mode 100644 index 00000000..e80c2e5b --- /dev/null +++ b/nanobot/channels/websocket/validation.py @@ -0,0 +1,23 @@ +"""WebSocket setup validation owned by the channel package.""" + +from typing import Any + +from nanobot.channels.contracts import ChannelValidationContext +from nanobot.channels.validation import check, enabled, official_action, payload + + +def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: + checks = [ + check( + "managed", + "Managed by WebUI", + "pass", + "The browser workbench prepares the local WebSocket channel.", + action_url=official_action("websocket"), + ) + ] + status = "connected" if enabled(values) else "configured" + return payload("websocket", status, checks, can_enable=True) + + +__all__ = ["validate"] diff --git a/nanobot/channels/websocket/webui/index.ts b/nanobot/channels/websocket/webui/index.ts new file mode 100644 index 00000000..94f9509c --- /dev/null +++ b/nanobot/channels/websocket/webui/index.ts @@ -0,0 +1,17 @@ +import { Network } from "lucide-react"; + +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "WebSocket", + initials: "WS", + color: "#111827", + icon: Network, + setup: { + mode: "webui", + docsUrl: chatAppGuideUrl("websocket"), + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/websocket/webui/locales/en.json b/nanobot/channels/websocket/webui/locales/en.json new file mode 100644 index 00000000..233631d8 --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/en.json @@ -0,0 +1,15 @@ +{ + "description": "Use nanobot from the local browser workbench.", + "requirements": "Local gateway, WebSocket token", + "setup": { + "docsLabel": "Open WebSocket setup", + "officialLabel": "Open local WebUI", + "tryIt": "Open the WebUI and send a short message.", + "summary": "WebSocket is required by the browser workbench and is prepared by the nanobot webui command.", + "steps": [ + "Start nanobot with the webui command.", + "Open the local URL printed in the terminal.", + "Keep WebSocket enabled while using the browser workbench." + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/es.json b/nanobot/channels/websocket/webui/locales/es.json new file mode 100644 index 00000000..8bc2b653 --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/es.json @@ -0,0 +1,15 @@ +{ + "description": "Usa nanobot desde el entorno de trabajo del navegador local.", + "requirements": "Gateway local y token WebSocket", + "setup": { + "docsLabel": "Abrir guía de WebSocket", + "officialLabel": "Abrir WebUI local", + "tryIt": "Abre la WebUI y envía un mensaje corto.", + "summary": "El entorno del navegador necesita WebSocket, que se prepara con el comando nanobot webui.", + "steps": [ + "Inicia nanobot con el comando webui.", + "Abre la URL local mostrada en la terminal.", + "Mantén WebSocket activado mientras uses el entorno del navegador." + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/fr.json b/nanobot/channels/websocket/webui/locales/fr.json new file mode 100644 index 00000000..7466fadd --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/fr.json @@ -0,0 +1,15 @@ +{ + "description": "Utilisez nanobot depuis l’espace de travail du navigateur local.", + "requirements": "Passerelle locale et jeton WebSocket", + "setup": { + "docsLabel": "Ouvrir le guide WebSocket", + "officialLabel": "Ouvrir la WebUI locale", + "tryIt": "Ouvrez la WebUI et envoyez un court message.", + "summary": "L’espace de travail du navigateur nécessite WebSocket, préparé par la commande nanobot webui.", + "steps": [ + "Démarrez nanobot avec la commande webui.", + "Ouvrez l’URL locale affichée dans le terminal.", + "Gardez WebSocket activé pendant l’utilisation de l’espace de travail." + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/id.json b/nanobot/channels/websocket/webui/locales/id.json new file mode 100644 index 00000000..4f3b3b9d --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/id.json @@ -0,0 +1,15 @@ +{ + "description": "Gunakan nanobot dari ruang kerja browser lokal.", + "requirements": "Gateway lokal dan token WebSocket", + "setup": { + "docsLabel": "Buka panduan WebSocket", + "officialLabel": "Buka WebUI lokal", + "tryIt": "Buka WebUI dan kirim pesan singkat.", + "summary": "Ruang kerja browser memerlukan WebSocket yang disiapkan oleh perintah nanobot webui.", + "steps": [ + "Mulai nanobot dengan perintah webui.", + "Buka URL lokal yang ditampilkan di terminal.", + "Biarkan WebSocket aktif saat menggunakan ruang kerja browser." + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/ja.json b/nanobot/channels/websocket/webui/locales/ja.json new file mode 100644 index 00000000..22fe1db5 --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/ja.json @@ -0,0 +1,15 @@ +{ + "description": "ローカルのブラウザワークベンチから nanobot を利用します。", + "requirements": "ローカルゲートウェイと WebSocket トークン", + "setup": { + "docsLabel": "WebSocket 設定ガイドを開く", + "officialLabel": "ローカル WebUI を開く", + "tryIt": "WebUI を開き、短いメッセージを送信します。", + "summary": "ブラウザワークベンチには WebSocket が必要で、nanobot webui コマンドによって準備されます。", + "steps": [ + "webui コマンドで nanobot を起動します。", + "ターミナルに表示されたローカル URL を開きます。", + "ブラウザワークベンチの使用中は WebSocket を有効にしておきます。" + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/ko.json b/nanobot/channels/websocket/webui/locales/ko.json new file mode 100644 index 00000000..a927645c --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/ko.json @@ -0,0 +1,15 @@ +{ + "description": "로컬 브라우저 워크벤치에서 nanobot을 사용합니다.", + "requirements": "로컬 게이트웨이 및 WebSocket 토큰", + "setup": { + "docsLabel": "WebSocket 설정 가이드 열기", + "officialLabel": "로컬 WebUI 열기", + "tryIt": "WebUI를 열고 짧은 메시지를 보내세요.", + "summary": "브라우저 워크벤치에는 WebSocket이 필요하며 nanobot webui 명령이 이를 준비합니다.", + "steps": [ + "webui 명령으로 nanobot을 시작하세요.", + "터미널에 표시된 로컬 URL을 여세요.", + "브라우저 워크벤치를 사용하는 동안 WebSocket을 활성화해 두세요." + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/pt-BR.json b/nanobot/channels/websocket/webui/locales/pt-BR.json new file mode 100644 index 00000000..c300cd77 --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/pt-BR.json @@ -0,0 +1,15 @@ +{ + "description": "Use o nanobot no ambiente de trabalho do navegador local.", + "requirements": "Gateway local e token WebSocket", + "setup": { + "docsLabel": "Abrir guia do WebSocket", + "officialLabel": "Abrir WebUI local", + "tryIt": "Abra a WebUI e envie uma mensagem curta.", + "summary": "O ambiente do navegador precisa do WebSocket, preparado pelo comando nanobot webui.", + "steps": [ + "Inicie o nanobot com o comando webui.", + "Abra a URL local exibida no terminal.", + "Mantenha o WebSocket ativado ao usar o ambiente do navegador." + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/vi.json b/nanobot/channels/websocket/webui/locales/vi.json new file mode 100644 index 00000000..d4e33c0f --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/vi.json @@ -0,0 +1,15 @@ +{ + "description": "Sử dụng nanobot từ không gian làm việc trên trình duyệt cục bộ.", + "requirements": "Gateway cục bộ và token WebSocket", + "setup": { + "docsLabel": "Mở hướng dẫn WebSocket", + "officialLabel": "Mở WebUI cục bộ", + "tryIt": "Mở WebUI và gửi một tin nhắn ngắn.", + "summary": "Không gian làm việc trên trình duyệt cần WebSocket, được chuẩn bị bởi lệnh nanobot webui.", + "steps": [ + "Khởi động nanobot bằng lệnh webui.", + "Mở URL cục bộ hiển thị trong terminal.", + "Giữ WebSocket bật khi sử dụng không gian làm việc trên trình duyệt." + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/zh-CN.json b/nanobot/channels/websocket/webui/locales/zh-CN.json new file mode 100644 index 00000000..25470674 --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/zh-CN.json @@ -0,0 +1,15 @@ +{ + "description": "通过本地浏览器工作台使用 nanobot。", + "requirements": "本地网关和 WebSocket 令牌", + "setup": { + "docsLabel": "打开 WebSocket 配置指南", + "officialLabel": "打开本地 WebUI", + "tryIt": "打开 WebUI 并发送一条简短消息。", + "summary": "浏览器工作台依赖 WebSocket;运行 nanobot webui 命令时会自动完成准备。", + "steps": [ + "使用 webui 命令启动 nanobot。", + "打开终端中显示的本地地址。", + "使用浏览器工作台期间请保持 WebSocket 启用。" + ] + } +} diff --git a/nanobot/channels/websocket/webui/locales/zh-TW.json b/nanobot/channels/websocket/webui/locales/zh-TW.json new file mode 100644 index 00000000..fa324c6d --- /dev/null +++ b/nanobot/channels/websocket/webui/locales/zh-TW.json @@ -0,0 +1,15 @@ +{ + "description": "透過本機瀏覽器工作台使用 nanobot。", + "requirements": "本機閘道和 WebSocket 權杖", + "setup": { + "docsLabel": "開啟 WebSocket 設定指南", + "officialLabel": "開啟本機 WebUI", + "tryIt": "開啟 WebUI 並傳送一則簡短訊息。", + "summary": "瀏覽器工作台需要 WebSocket;執行 nanobot webui 指令時會自動完成準備。", + "steps": [ + "使用 webui 指令啟動 nanobot。", + "開啟終端機中顯示的本機網址。", + "使用瀏覽器工作台期間請保持 WebSocket 啟用。" + ] + } +} diff --git a/nanobot/channels/wecom/__init__.py b/nanobot/channels/wecom/__init__.py new file mode 100644 index 00000000..54d6c882 --- /dev/null +++ b/nanobot/channels/wecom/__init__.py @@ -0,0 +1 @@ +"""WeCom channel package.""" diff --git a/nanobot/channels/wecom/manifest.py b/nanobot/channels/wecom/manifest.py new file mode 100644 index 00000000..82c5d00e --- /dev/null +++ b/nanobot/channels/wecom/manifest.py @@ -0,0 +1,24 @@ +"""WeCom 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={ + "botId": field(), + "secret": field("secret"), + "allowFrom": field("list"), + }, + required=required_fields("botId", "secret"), + official_url="https://developer.work.weixin.qq.com/", +) + +PLUGIN = ChannelPlugin( + name="wecom", + display_name="WeCom", + runtime=f"{__package__}.runtime:WecomChannel", + setup=SETUP_SPEC, + dependencies=("wecom-aibot-sdk-python>=0.1.5",), + webui="webui/index.ts", +) diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom/runtime.py similarity index 100% rename from nanobot/channels/wecom.py rename to nanobot/channels/wecom/runtime.py diff --git a/nanobot/channels/wecom/tests/__init__.py b/nanobot/channels/wecom/tests/__init__.py new file mode 100644 index 00000000..4928f5a7 --- /dev/null +++ b/nanobot/channels/wecom/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the WeCom channel package.""" diff --git a/tests/channels/test_wecom_channel.py b/nanobot/channels/wecom/tests/test_wecom_channel.py similarity index 96% rename from tests/channels/test_wecom_channel.py rename to nanobot/channels/wecom/tests/test_wecom_channel.py index 9079feb7..f44ad81b 100644 --- a/tests/channels/test_wecom_channel.py +++ b/nanobot/channels/wecom/tests/test_wecom_channel.py @@ -20,7 +20,7 @@ if not WECOM_AVAILABLE: from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus -from nanobot.channels.wecom import ( +from nanobot.channels.wecom.runtime import ( WecomChannel, WecomConfig, _guess_wecom_media_type, @@ -134,7 +134,7 @@ async def test_download_and_save_success() -> None: fake_data = b"\x89PNG\r\nfake image" client.download_file.return_value = (fake_data, "raw_photo.png") - with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(tempfile.gettempdir())): + with patch("nanobot.channels.wecom.runtime.get_media_dir", return_value=Path(tempfile.gettempdir())): path = await channel._download_and_save_media("https://example.com/img.png", "aes_key", "image", "photo.png") assert path is not None @@ -154,7 +154,7 @@ async def test_download_and_save_oversized_rejected() -> None: big_data = b"\x00" * (200 * 1024 * 1024 + 1) # 200MB + 1 byte client.download_file.return_value = (big_data, "big.bin") - with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(tempfile.gettempdir())): + with patch("nanobot.channels.wecom.runtime.get_media_dir", return_value=Path(tempfile.gettempdir())): result = await channel._download_and_save_media("https://example.com/big.bin", "key", "file", "big.bin") assert result is None @@ -169,7 +169,7 @@ async def test_download_and_save_failure() -> None: client.download_file.return_value = (None, None) - with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(tempfile.gettempdir())): + with patch("nanobot.channels.wecom.runtime.get_media_dir", return_value=Path(tempfile.gettempdir())): result = await channel._download_and_save_media("https://example.com/fail.png", "key", "image") assert result is None @@ -504,7 +504,7 @@ async def test_process_image_message() -> None: channel._client = client try: - with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))): + with patch("nanobot.channels.wecom.runtime.get_media_dir", return_value=Path(os.path.dirname(saved))): frame = _FakeFrame(body={ "msgid": "msg_img_1", "chatid": "chat1", @@ -540,7 +540,7 @@ async def test_process_file_message() -> None: channel._client = client try: - with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))): + with patch("nanobot.channels.wecom.runtime.get_media_dir", return_value=Path(os.path.dirname(saved))): frame = _FakeFrame(body={ "msgid": "msg_file_1", "chatid": "chat1", @@ -567,7 +567,7 @@ async def test_process_file_message_uses_sdk_filename_when_name_missing(tmp_path client.download_file.return_value = (b"%PDF-1.4 fake", "real_name.pdf") channel._client = client - with patch("nanobot.channels.wecom.get_media_dir", return_value=tmp_path): + with patch("nanobot.channels.wecom.runtime.get_media_dir", return_value=tmp_path): frame = _FakeFrame(body={ "msgid": "msg_file_2", "chatid": "chat1", "from": {"userid": "user1"}, "file": {"url": "https://example.com/x", "aeskey": "key456"}, @@ -614,7 +614,7 @@ async def test_process_mixed_message() -> None: channel._client = client try: - with patch("nanobot.channels.wecom.get_media_dir", return_value=Path(os.path.dirname(saved))): + with patch("nanobot.channels.wecom.runtime.get_media_dir", return_value=Path(os.path.dirname(saved))): frame = _FakeFrame(body={ "msgid": "msg_mixed_1", "chatid": "chat1", diff --git a/nanobot/channels/wecom/webui/index.ts b/nanobot/channels/wecom/webui/index.ts new file mode 100644 index 00000000..cddc8047 --- /dev/null +++ b/nanobot/channels/wecom/webui/index.ts @@ -0,0 +1,20 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "WeCom", + initials: "WC", + color: "#2F7DFF", + logoUrl: "https://work.weixin.qq.com/favicon.ico", + setup: { + mode: "credentials", + docsUrl: chatAppGuideUrl("wecom"), + fields: [ + { key: "channels.wecom.botId" }, + { key: "channels.wecom.secret" }, + { key: "channels.wecom.allowFrom" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/wecom/webui/locales/en.json b/nanobot/channels/wecom/webui/locales/en.json new file mode 100644 index 00000000..452af3fa --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/en.json @@ -0,0 +1,31 @@ +{ + "description": "Use nanobot from WeCom work chats.", + "requirements": "WeCom app credentials and callback settings", + "setup": { + "docsLabel": "Open WeCom setup", + "officialLabel": "Open WeCom console", + "tryIt": "Send a test message to the WeCom bot.", + "summary": "WeCom needs an AI bot ID and secret from the WeCom admin console.", + "steps": [ + "Create an AI bot in WeCom and choose API mode.", + "Copy the Bot ID and Secret into nanobot.", + "Save and enable WeCom, then send a test message." + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "WeCom bot ID", + "help": "Copy it from the WeCom AI Bot API mode page." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Keep the WeCom bot secret private." + }, + "allowFrom": { + "label": "Allowed users", + "placeholder": "User IDs, comma separated" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/es.json b/nanobot/channels/wecom/webui/locales/es.json new file mode 100644 index 00000000..ca0c274d --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/es.json @@ -0,0 +1,31 @@ +{ + "description": "Usa nanobot desde chats de trabajo de WeCom.", + "requirements": "Credenciales de la app WeCom y ajustes de callback", + "setup": { + "docsLabel": "Abrir guía de WeCom", + "officialLabel": "Abrir consola de WeCom", + "tryIt": "Envía un mensaje de prueba al bot de WeCom.", + "summary": "WeCom necesita el ID y el secreto de un bot de IA desde la consola de administración.", + "steps": [ + "Crea un bot de IA en WeCom y elige el modo API.", + "Copia el Bot ID y el Secret en nanobot.", + "Guarda y activa WeCom; después envía un mensaje de prueba." + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "Bot ID de WeCom", + "help": "Cópialo de la página del modo API del bot de IA de WeCom." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Mantén privado el secreto del bot de WeCom." + }, + "allowFrom": { + "label": "Usuarios permitidos", + "placeholder": "ID de usuario separados por comas" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/fr.json b/nanobot/channels/wecom/webui/locales/fr.json new file mode 100644 index 00000000..281cb989 --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/fr.json @@ -0,0 +1,31 @@ +{ + "description": "Utilisez nanobot dans les conversations professionnelles WeCom.", + "requirements": "Identifiants d’application WeCom et paramètres de rappel", + "setup": { + "docsLabel": "Ouvrir le guide WeCom", + "officialLabel": "Ouvrir la console WeCom", + "tryIt": "Envoyez un message test au bot WeCom.", + "summary": "WeCom nécessite l’ID et le secret d’un bot IA depuis la console d’administration.", + "steps": [ + "Créez un bot IA dans WeCom et choisissez le mode API.", + "Copiez le Bot ID et le Secret dans nanobot.", + "Enregistrez et activez WeCom, puis envoyez un message test." + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "Bot ID WeCom", + "help": "Copiez-le depuis la page du mode API du bot IA WeCom." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Gardez le secret du bot WeCom confidentiel." + }, + "allowFrom": { + "label": "Utilisateurs autorisés", + "placeholder": "ID utilisateur séparés par des virgules" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/id.json b/nanobot/channels/wecom/webui/locales/id.json new file mode 100644 index 00000000..249bc59d --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/id.json @@ -0,0 +1,31 @@ +{ + "description": "Gunakan nanobot dari chat kerja WeCom.", + "requirements": "Kredensial aplikasi WeCom dan pengaturan callback", + "setup": { + "docsLabel": "Buka panduan WeCom", + "officialLabel": "Buka konsol WeCom", + "tryIt": "Kirim pesan uji ke bot WeCom.", + "summary": "WeCom memerlukan ID dan secret bot AI dari konsol admin.", + "steps": [ + "Buat bot AI di WeCom dan pilih mode API.", + "Salin Bot ID dan Secret ke nanobot.", + "Simpan dan aktifkan WeCom, lalu kirim pesan uji." + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "Bot ID WeCom", + "help": "Salin dari halaman mode API Bot AI WeCom." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Jaga kerahasiaan secret bot WeCom." + }, + "allowFrom": { + "label": "Pengguna yang diizinkan", + "placeholder": "ID pengguna, dipisahkan koma" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/ja.json b/nanobot/channels/wecom/webui/locales/ja.json new file mode 100644 index 00000000..3fc835ea --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/ja.json @@ -0,0 +1,31 @@ +{ + "description": "WeCom の業務チャットから nanobot を利用します。", + "requirements": "WeCom アプリの認証情報とコールバック設定", + "setup": { + "docsLabel": "WeCom 設定ガイドを開く", + "officialLabel": "WeCom コンソールを開く", + "tryIt": "WeCom ボットにテストメッセージを送信します。", + "summary": "WeCom 管理コンソールの AI ボット ID とシークレットが必要です。", + "steps": [ + "WeCom で AI ボットを作成し、API モードを選択します。", + "Bot ID と Secret を nanobot にコピーします。", + "保存して WeCom を有効にし、テストメッセージを送信します。" + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "WeCom Bot ID", + "help": "WeCom AI ボットの API モードページからコピーします。" + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "WeCom ボットのシークレットは安全に保管してください。" + }, + "allowFrom": { + "label": "許可するユーザー", + "placeholder": "ユーザー ID(カンマ区切り)" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/ko.json b/nanobot/channels/wecom/webui/locales/ko.json new file mode 100644 index 00000000..b10842e0 --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/ko.json @@ -0,0 +1,31 @@ +{ + "description": "WeCom 업무 채팅에서 nanobot을 사용합니다.", + "requirements": "WeCom 앱 자격 증명 및 콜백 설정", + "setup": { + "docsLabel": "WeCom 설정 가이드 열기", + "officialLabel": "WeCom 콘솔 열기", + "tryIt": "WeCom 봇에 테스트 메시지를 보내세요.", + "summary": "WeCom 관리 콘솔의 AI 봇 ID와 Secret이 필요합니다.", + "steps": [ + "WeCom에서 AI 봇을 만들고 API 모드를 선택하세요.", + "Bot ID와 Secret을 nanobot에 복사하세요.", + "저장하고 WeCom을 활성화한 다음 테스트 메시지를 보내세요." + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "WeCom Bot ID", + "help": "WeCom AI 봇 API 모드 페이지에서 복사하세요." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "WeCom 봇 Secret을 안전하게 보관하세요." + }, + "allowFrom": { + "label": "허용된 사용자", + "placeholder": "사용자 ID, 쉼표로 구분" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/pt-BR.json b/nanobot/channels/wecom/webui/locales/pt-BR.json new file mode 100644 index 00000000..1494475a --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/pt-BR.json @@ -0,0 +1,31 @@ +{ + "description": "Use o nanobot em conversas de trabalho do WeCom.", + "requirements": "Credenciais do app WeCom e configurações de callback", + "setup": { + "docsLabel": "Abrir guia do WeCom", + "officialLabel": "Abrir console do WeCom", + "tryIt": "Envie uma mensagem de teste ao bot do WeCom.", + "summary": "O WeCom precisa do ID e do segredo de um bot de IA do console administrativo.", + "steps": [ + "Crie um bot de IA no WeCom e escolha o modo API.", + "Copie o Bot ID e o Secret para o nanobot.", + "Salve e ative o WeCom; depois, envie uma mensagem de teste." + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "Bot ID do WeCom", + "help": "Copie da página do modo API do bot de IA do WeCom." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Mantenha privado o segredo do bot do WeCom." + }, + "allowFrom": { + "label": "Usuários permitidos", + "placeholder": "IDs de usuário separados por vírgulas" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/vi.json b/nanobot/channels/wecom/webui/locales/vi.json new file mode 100644 index 00000000..08395176 --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/vi.json @@ -0,0 +1,31 @@ +{ + "description": "Sử dụng nanobot trong các cuộc trò chuyện công việc WeCom.", + "requirements": "Thông tin xác thực ứng dụng WeCom và cài đặt callback", + "setup": { + "docsLabel": "Mở hướng dẫn WeCom", + "officialLabel": "Mở bảng điều khiển WeCom", + "tryIt": "Gửi tin nhắn thử đến bot WeCom.", + "summary": "WeCom cần ID và secret của bot AI từ bảng điều khiển quản trị.", + "steps": [ + "Tạo bot AI trong WeCom và chọn chế độ API.", + "Sao chép Bot ID và Secret vào nanobot.", + "Lưu và bật WeCom, sau đó gửi tin nhắn thử." + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "Bot ID WeCom", + "help": "Sao chép từ trang chế độ API của bot AI WeCom." + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "Giữ bí mật secret của bot WeCom." + }, + "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" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/zh-CN.json b/nanobot/channels/wecom/webui/locales/zh-CN.json new file mode 100644 index 00000000..8623ee86 --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/zh-CN.json @@ -0,0 +1,32 @@ +{ + "displayName": "企业微信", + "description": "在企业微信工作会话中使用 nanobot。", + "requirements": "企业微信应用凭据和回调设置", + "setup": { + "docsLabel": "打开企业微信配置指南", + "officialLabel": "打开企业微信管理后台", + "tryIt": "向企业微信机器人发送一条测试消息。", + "summary": "企业微信需要管理后台中 AI 机器人的 Bot ID 和 Secret。", + "steps": [ + "在企业微信中创建 AI 机器人并选择 API 模式。", + "将 Bot ID 和 Secret 复制到 nanobot。", + "保存并启用企业微信,然后发送一条测试消息。" + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "企业微信 Bot ID", + "help": "从企业微信 AI 机器人 API 模式页面复制。" + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "请妥善保管企业微信机器人 Secret。" + }, + "allowFrom": { + "label": "允许的用户", + "placeholder": "用户 ID,用逗号分隔" + } + } + } +} diff --git a/nanobot/channels/wecom/webui/locales/zh-TW.json b/nanobot/channels/wecom/webui/locales/zh-TW.json new file mode 100644 index 00000000..659bb0da --- /dev/null +++ b/nanobot/channels/wecom/webui/locales/zh-TW.json @@ -0,0 +1,32 @@ +{ + "displayName": "企業微信", + "description": "在企業微信工作對話中使用 nanobot。", + "requirements": "企業微信應用程式憑證和回呼設定", + "setup": { + "docsLabel": "開啟企業微信設定指南", + "officialLabel": "開啟企業微信管理後台", + "tryIt": "向企業微信機器人傳送一則測試訊息。", + "summary": "企業微信需要管理後台中 AI 機器人的 Bot ID 和 Secret。", + "steps": [ + "在企業微信中建立 AI 機器人並選擇 API 模式。", + "將 Bot ID 和 Secret 複製到 nanobot。", + "儲存並啟用企業微信,然後傳送一則測試訊息。" + ], + "fields": { + "botId": { + "label": "Bot ID", + "placeholder": "企業微信 Bot ID", + "help": "從企業微信 AI 機器人 API 模式頁面複製。" + }, + "secret": { + "label": "Secret", + "placeholder": "••••••", + "help": "請妥善保管企業微信機器人 Secret。" + }, + "allowFrom": { + "label": "允許的使用者", + "placeholder": "使用者 ID,以逗號分隔" + } + } + } +} diff --git a/nanobot/channels/weixin/__init__.py b/nanobot/channels/weixin/__init__.py new file mode 100644 index 00000000..f3843e89 --- /dev/null +++ b/nanobot/channels/weixin/__init__.py @@ -0,0 +1 @@ +"""Personal WeChat channel package.""" diff --git a/nanobot/webui/channel_connect.py b/nanobot/channels/weixin/connect.py similarity index 52% rename from nanobot/webui/channel_connect.py rename to nanobot/channels/weixin/connect.py index 10c3eea3..a56a7a2f 100644 --- a/nanobot/webui/channel_connect.py +++ b/nanobot/channels/weixin/connect.py @@ -1,8 +1,7 @@ -"""Short-lived WebUI channel connection sessions.""" +"""WeChat-owned interactive connection flow.""" from __future__ import annotations -import json import secrets import time from contextlib import suppress @@ -11,201 +10,10 @@ from typing import Any import httpx -from nanobot.channels import feishu -from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID, validate_instance_id +from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first from nanobot.config.loader import load_config -class ChannelConnectError(Exception): - """User-facing channel connect failure.""" - - def __init__(self, message: str, *, status: int = 400) -> None: - super().__init__(message) - self.message = message - self.status = status - - -@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] = {} - - 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": - 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.", - } - - @dataclass(slots=True) class WeixinConnectSession: id: str @@ -220,23 +28,36 @@ class WeixinConnectSession: class WeixinConnectStore: - """In-memory WeChat QR login sessions for the WebUI. - - WeChat login writes local account state only after scan confirmation. A - cancelled or expired browser flow leaves any existing account state intact. - """ + """In-memory WeChat QR login sessions for the WebUI.""" def __init__(self) -> None: self._sessions: dict[str, WeixinConnectSession] = {} + async def handle(self, action: str, query: QueryParams) -> dict[str, Any]: + """Handle one generic settings connection action.""" + if action == "start": + force = (query_first(query, "force") or "").strip().lower() in { + "1", + "true", + "yes", + } + return await self.start(force=force) + + session_id = (query_first(query, "session_id") or "").strip() + if not session_id: + raise ChannelConnectError("missing WeChat connect session") + if action == "poll": + return await self.poll(session_id) + if action == "cancel": + return await self.cancel(session_id) + raise ChannelConnectError(f"unsupported WeChat connect action: {action}", status=404) + async def start(self, *, force: bool = False) -> dict[str, Any]: await self._cleanup() channel = self._build_channel() if force: - # Start a fresh login flow without touching the currently working - # account. A confirmed scan replaces it via _save_state; - # cancellation or expiry must leave the old account usable. + # Preserve the working account until a replacement scan succeeds. channel._token = "" channel._get_updates_buf = "" elif channel._load_state(): @@ -256,7 +77,10 @@ class WeixinConnectStore: qrcode_id, qr_url = await channel._fetch_qr_code() except Exception as exc: await self._close_channel(channel) - raise ChannelConnectError(f"Unable to start WeChat QR login: {exc}", status=502) from exc + raise ChannelConnectError( + f"Unable to start WeChat QR login: {exc}", + status=502, + ) from exc session_id = secrets.token_urlsafe(18) now_wall = time.time() @@ -332,16 +156,15 @@ class WeixinConnectStore: if status == "scaned_but_redirect": redirect_host = str(status_data.get("redirect_host", "") or "").strip() if redirect_host: - redirected_base = ( + session.current_poll_base_url = ( redirect_host if redirect_host.startswith(("http://", "https://")) else f"https://{redirect_host}" ) - session.current_poll_base_url = redirected_base return self._pending_payload(session) if status == "expired": - from nanobot.channels.weixin import MAX_QR_REFRESH_COUNT + from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT session.refresh_count += 1 if session.refresh_count > MAX_QR_REFRESH_COUNT: @@ -392,7 +215,7 @@ class WeixinConnectStore: @staticmethod def _build_channel() -> Any: from nanobot.bus.queue import MessageBus - from nanobot.channels.weixin import WeixinChannel + from nanobot.channels.weixin.runtime import WeixinChannel section = getattr(load_config().channels, "weixin", None) if hasattr(section, "model_dump"): @@ -433,3 +256,6 @@ class WeixinConnectStore: "expires_at_ms": int((session.created_wall + 600) * 1000), "message": "Waiting for WeChat scan.", } + + +__all__ = ["WeixinConnectStore"] diff --git a/nanobot/channels/weixin/manifest.py b/nanobot/channels/weixin/manifest.py new file mode 100644 index 00000000..35024189 --- /dev/null +++ b/nanobot/channels/weixin/manifest.py @@ -0,0 +1,31 @@ +"""WeChat management contract.""" + +from nanobot.channels._manifest import field, required +from nanobot.channels.contracts import ChannelManagementSpec, ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin +from nanobot.channels.weixin.state import local_state_present +from nanobot.channels.weixin.validation import validate + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "token": field("secret"), + "allowFrom": field("list"), + }, + required=(required("token"),), + official_url="https://weixin.qq.com/", + validator=validate, +) + +PLUGIN = ChannelPlugin( + name="weixin", + display_name="WeChat", + runtime=f"{__package__}.runtime:WeixinChannel", + connector=f"{__package__}.connect:WeixinConnectStore", + setup=SETUP_SPEC, + management=ChannelManagementSpec(local_state_present=local_state_present), + dependencies=( + "qrcode[pil]>=8.0", + "pycryptodome>=3.20.0", + ), + webui="webui/index.tsx", +) diff --git a/nanobot/channels/weixin.py b/nanobot/channels/weixin/runtime.py similarity index 100% rename from nanobot/channels/weixin.py rename to nanobot/channels/weixin/runtime.py diff --git a/nanobot/channels/weixin/state.py b/nanobot/channels/weixin/state.py new file mode 100644 index 00000000..42ac8b6d --- /dev/null +++ b/nanobot/channels/weixin/state.py @@ -0,0 +1,27 @@ +"""WeChat-owned persisted login-state detection.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from nanobot.channels.contracts import channel_field_value +from nanobot.config.loader import get_config_path + + +def local_state_present(section: Any) -> bool: + configured_dir = channel_field_value(section, "stateDir") + state_dir = ( + Path(str(configured_dir)).expanduser() + if configured_dir + else get_config_path().parent / "weixin" + ) + try: + payload = json.loads((state_dir / "account.json").read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return False + return bool(str(payload.get("token") or "").strip()) + + +__all__ = ["local_state_present"] diff --git a/nanobot/channels/weixin/tests/__init__.py b/nanobot/channels/weixin/tests/__init__.py new file mode 100644 index 00000000..2f8221a4 --- /dev/null +++ b/nanobot/channels/weixin/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Weixin channel package.""" diff --git a/tests/webui/test_channel_connect.py b/nanobot/channels/weixin/tests/test_connect.py similarity index 96% rename from tests/webui/test_channel_connect.py rename to nanobot/channels/weixin/tests/test_connect.py index c6b78a2d..47e4a825 100644 --- a/tests/webui/test_channel_connect.py +++ b/nanobot/channels/weixin/tests/test_connect.py @@ -5,10 +5,10 @@ from typing import Any import pytest -from nanobot.channels.weixin import WeixinChannel +from nanobot.channels.weixin.connect import WeixinConnectStore +from nanobot.channels.weixin.runtime import WeixinChannel from nanobot.config.loader import save_config from nanobot.config.schema import Config -from nanobot.webui.channel_connect import WeixinConnectStore @pytest.mark.asyncio diff --git a/tests/channels/test_weixin_channel.py b/nanobot/channels/weixin/tests/test_weixin_channel.py similarity index 99% rename from tests/channels/test_weixin_channel.py rename to nanobot/channels/weixin/tests/test_weixin_channel.py index f654c3ae..d0c1c6e4 100644 --- a/tests/channels/test_weixin_channel.py +++ b/nanobot/channels/weixin/tests/test_weixin_channel.py @@ -9,10 +9,10 @@ from unittest.mock import AsyncMock import httpx import pytest -import nanobot.channels.weixin as weixin_mod from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.queue import MessageBus -from nanobot.channels.weixin import ( +from nanobot.channels.weixin import runtime as weixin_mod +from nanobot.channels.weixin.runtime import ( ITEM_IMAGE, ITEM_TEXT, MESSAGE_TYPE_BOT, diff --git a/nanobot/channels/weixin/validation.py b/nanobot/channels/weixin/validation.py new file mode 100644 index 00000000..dad6f347 --- /dev/null +++ b/nanobot/channels/weixin/validation.py @@ -0,0 +1,34 @@ +"""WeChat setup validation owned by the channel package.""" + +from typing import Any + +from nanobot.channels.contracts import ChannelValidationContext +from nanobot.channels.validation import check, enabled, official_action, payload, string_value + + +def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + if enabled(values) or string_value(values.get("token")): + checks.append( + check("local_state", "Local login state", "pass", "Saved local login state was detected.") + ) + return payload("weixin", "configured", checks, can_enable=True) + checks.append( + check( + "terminal_login", + "Terminal login", + "skipped", + "This channel uses a terminal QR login flow.", + action_url=official_action("weixin"), + ) + ) + return payload( + "weixin", + "needs_setup", + checks, + missing_fields=["terminal_login"], + can_enable=False, + ) + + +__all__ = ["validate"] diff --git a/nanobot/channels/weixin/webui/WeixinConnectFlow.tsx b/nanobot/channels/weixin/webui/WeixinConnectFlow.tsx new file mode 100644 index 00000000..79c06b24 --- /dev/null +++ b/nanobot/channels/weixin/webui/WeixinConnectFlow.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; + +import { channelTranslator } from "@/channel-plugins/i18n"; +import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types"; +import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow"; + +export function WeixinConnectFlow({ + token, + idleLabel, + connectRequestId, + onFeaturesUpdate, +}: ChannelPluginConnectFlowProps) { + const { t } = useTranslation(); + const tx = channelTranslator(t, "weixin"); + return ( + + ); +} diff --git a/nanobot/channels/weixin/webui/index.tsx b/nanobot/channels/weixin/webui/index.tsx new file mode 100644 index 00000000..718f0493 --- /dev/null +++ b/nanobot/channels/weixin/webui/index.tsx @@ -0,0 +1,27 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +import { WeixinConnectFlow } from "./WeixinConnectFlow"; + +export default { + ConnectFlow: WeixinConnectFlow, + canConnectBeforeConfigured: true, + aliases: { + wechat: {}, + }, + presentation: { + displayName: "WeChat", + initials: "WX", + color: "#07C160", + logoUrl: "https://weixin.qq.com/favicon.ico", + setup: { + mode: "connect", + command: "nanobot channels login weixin", + docsUrl: chatAppGuideUrl("wechat"), + manualFields: [ + { key: "channels.weixin.allowFrom" }, + { key: "channels.weixin.token" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/weixin/webui/locales/en.json b/nanobot/channels/weixin/webui/locales/en.json new file mode 100644 index 00000000..a1568c51 --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/en.json @@ -0,0 +1,35 @@ +{ + "description": "Use nanobot from WeChat conversations.", + "requirements": "WeChat channel setup and gateway", + "setup": { + "primaryAction": "Connect WeChat", + "docsLabel": "Open WeChat setup", + "officialLabel": "Open WeChat", + "tryIt": "After the QR login finishes, send a WeChat DM to the connected account.", + "summary": "WeChat signs in with a QR code and saves the account state locally.", + "steps": [ + "Click Connect and scan the QR code with WeChat on your phone.", + "Keep the local gateway running while WeChat receives messages.", + "Send a direct test message to confirm the account is connected." + ], + "fields": { + "allowFrom": { + "label": "Allowed users", + "placeholder": "User IDs, comma separated" + }, + "token": { + "label": "Token", + "placeholder": "Saved by QR login" + } + } + }, + "custom": { + "qrAlt": "WeChat login QR code", + "scanTitle": "Scan with WeChat", + "scanDescription": "Use WeChat on your phone to scan this code. nanobot saves the account state locally after login.", + "waiting": "Waiting for WeChat scan...", + "connected": "WeChat is connected.", + "stopped": "WeChat login stopped.", + "connecting": "Connecting..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/es.json b/nanobot/channels/weixin/webui/locales/es.json new file mode 100644 index 00000000..60fb594c --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/es.json @@ -0,0 +1,35 @@ +{ + "description": "Usa nanobot desde conversaciones de WeChat.", + "requirements": "Configuración del canal WeChat y gateway", + "setup": { + "primaryAction": "Conectar WeChat", + "docsLabel": "Abrir guía de WeChat", + "officialLabel": "Abrir WeChat", + "tryIt": "Tras iniciar sesión por QR, envía un DM al usuario conectado.", + "summary": "WeChat inicia sesión con un QR y guarda el estado de la cuenta localmente.", + "steps": [ + "Haz clic en Conectar y escanea el QR con WeChat.", + "Mantén el gateway local activo mientras WeChat recibe mensajes.", + "Envía un mensaje directo de prueba para confirmar la conexión." + ], + "fields": { + "allowFrom": { + "label": "Usuarios permitidos", + "placeholder": "ID de usuario separados por comas" + }, + "token": { + "label": "Token", + "placeholder": "Guardado al iniciar sesión por QR" + } + } + }, + "custom": { + "qrAlt": "Código QR de inicio de WeChat", + "scanTitle": "Escanea con WeChat", + "scanDescription": "Escanea con WeChat en tu teléfono. nanobot guarda el estado localmente después del inicio.", + "waiting": "Esperando el escaneo de WeChat...", + "connected": "WeChat está conectado.", + "stopped": "Inicio de WeChat detenido.", + "connecting": "Conectando..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/fr.json b/nanobot/channels/weixin/webui/locales/fr.json new file mode 100644 index 00000000..aceda777 --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/fr.json @@ -0,0 +1,35 @@ +{ + "description": "Utilisez nanobot depuis les conversations WeChat.", + "requirements": "Configuration du canal WeChat et passerelle", + "setup": { + "primaryAction": "Connecter WeChat", + "docsLabel": "Ouvrir le guide WeChat", + "officialLabel": "Ouvrir WeChat", + "tryIt": "Après la connexion par QR code, envoyez un message privé au compte connecté.", + "summary": "WeChat se connecte par QR code et enregistre localement l’état du compte.", + "steps": [ + "Cliquez sur Connecter et scannez le QR code avec WeChat.", + "Gardez la passerelle locale active pendant la réception des messages.", + "Envoyez un message privé test pour confirmer la connexion." + ], + "fields": { + "allowFrom": { + "label": "Utilisateurs autorisés", + "placeholder": "ID utilisateur séparés par des virgules" + }, + "token": { + "label": "Jeton", + "placeholder": "Enregistré après la connexion QR" + } + } + }, + "custom": { + "qrAlt": "QR code de connexion WeChat", + "scanTitle": "Scanner avec WeChat", + "scanDescription": "Scannez ce code avec WeChat. nanobot enregistre localement l’état du compte après la connexion.", + "waiting": "En attente du scan WeChat...", + "connected": "WeChat est connecté.", + "stopped": "Connexion WeChat arrêtée.", + "connecting": "Connexion..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/id.json b/nanobot/channels/weixin/webui/locales/id.json new file mode 100644 index 00000000..d485f0ce --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/id.json @@ -0,0 +1,35 @@ +{ + "description": "Gunakan nanobot dari percakapan WeChat.", + "requirements": "Setup channel WeChat dan gateway", + "setup": { + "primaryAction": "Hubungkan WeChat", + "docsLabel": "Buka panduan WeChat", + "officialLabel": "Buka WeChat", + "tryIt": "Setelah login QR, kirim DM ke akun yang terhubung.", + "summary": "WeChat login dengan kode QR dan menyimpan status akun secara lokal.", + "steps": [ + "Klik Hubungkan dan pindai QR dengan WeChat.", + "Biarkan gateway lokal berjalan saat WeChat menerima pesan.", + "Kirim pesan langsung uji untuk memastikan akun terhubung." + ], + "fields": { + "allowFrom": { + "label": "Pengguna yang diizinkan", + "placeholder": "ID pengguna, dipisahkan koma" + }, + "token": { + "label": "Token", + "placeholder": "Disimpan saat login QR" + } + } + }, + "custom": { + "qrAlt": "Kode QR login WeChat", + "scanTitle": "Pindai dengan WeChat", + "scanDescription": "Pindai dengan WeChat di ponsel. nanobot menyimpan status akun secara lokal setelah login.", + "waiting": "Menunggu pemindaian WeChat...", + "connected": "WeChat sudah terhubung.", + "stopped": "Login WeChat dihentikan.", + "connecting": "Menghubungkan..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/ja.json b/nanobot/channels/weixin/webui/locales/ja.json new file mode 100644 index 00000000..4a4f1924 --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/ja.json @@ -0,0 +1,35 @@ +{ + "description": "WeChat の会話から nanobot を利用します。", + "requirements": "WeChat チャンネル設定とゲートウェイ", + "setup": { + "primaryAction": "WeChat に接続", + "docsLabel": "WeChat 設定ガイドを開く", + "officialLabel": "WeChat を開く", + "tryIt": "QR ログイン後、接続したアカウントに WeChat の DM を送信します。", + "summary": "WeChat は QR コードでログインし、アカウント状態をローカルに保存します。", + "steps": [ + "接続をクリックし、スマートフォンの WeChat で QR コードを読み取ります。", + "WeChat がメッセージを受信する間、ローカルゲートウェイを起動しておきます。", + "DM でテストし、アカウントの接続を確認します。" + ], + "fields": { + "allowFrom": { + "label": "許可するユーザー", + "placeholder": "ユーザー ID(カンマ区切り)" + }, + "token": { + "label": "トークン", + "placeholder": "QR ログインで保存" + } + } + }, + "custom": { + "qrAlt": "WeChat ログイン QR コード", + "scanTitle": "WeChat でスキャン", + "scanDescription": "スマートフォンの WeChat でスキャンしてください。ログイン後、nanobot が状態をローカルに保存します。", + "waiting": "WeChat のスキャンを待っています...", + "connected": "WeChat に接続しました。", + "stopped": "WeChat ログインを停止しました。", + "connecting": "接続中..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/ko.json b/nanobot/channels/weixin/webui/locales/ko.json new file mode 100644 index 00000000..0835da4a --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/ko.json @@ -0,0 +1,35 @@ +{ + "description": "WeChat 대화에서 nanobot을 사용합니다.", + "requirements": "WeChat 채널 설정 및 게이트웨이", + "setup": { + "primaryAction": "WeChat 연결", + "docsLabel": "WeChat 설정 가이드 열기", + "officialLabel": "WeChat 열기", + "tryIt": "QR 로그인 후 연결된 계정으로 WeChat DM을 보내세요.", + "summary": "WeChat은 QR 코드로 로그인하고 계정 상태를 로컬에 저장합니다.", + "steps": [ + "연결을 클릭하고 휴대폰 WeChat으로 QR 코드를 스캔하세요.", + "WeChat이 메시지를 받는 동안 로컬 게이트웨이를 실행해 두세요.", + "DM으로 테스트해 계정 연결을 확인하세요." + ], + "fields": { + "allowFrom": { + "label": "허용된 사용자", + "placeholder": "사용자 ID, 쉼표로 구분" + }, + "token": { + "label": "토큰", + "placeholder": "QR 로그인으로 저장됨" + } + } + }, + "custom": { + "qrAlt": "WeChat 로그인 QR 코드", + "scanTitle": "WeChat으로 스캔", + "scanDescription": "휴대폰 WeChat으로 스캔하세요. 로그인 후 nanobot이 계정 상태를 로컬에 저장합니다.", + "waiting": "WeChat 스캔을 기다리는 중...", + "connected": "WeChat이 연결되었습니다.", + "stopped": "WeChat 로그인이 중지되었습니다.", + "connecting": "연결 중..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/pt-BR.json b/nanobot/channels/weixin/webui/locales/pt-BR.json new file mode 100644 index 00000000..21176490 --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/pt-BR.json @@ -0,0 +1,35 @@ +{ + "description": "Use o nanobot em conversas do WeChat.", + "requirements": "Configuração do canal WeChat e gateway", + "setup": { + "primaryAction": "Conectar WeChat", + "docsLabel": "Abrir guia do WeChat", + "officialLabel": "Abrir WeChat", + "tryIt": "Após o login por QR, envie uma DM à conta conectada.", + "summary": "O WeChat entra por QR code e salva o estado da conta localmente.", + "steps": [ + "Clique em Conectar e escaneie o QR com o WeChat.", + "Mantenha o gateway local ativo enquanto o WeChat recebe mensagens.", + "Envie uma mensagem direta de teste para confirmar a conexão." + ], + "fields": { + "allowFrom": { + "label": "Usuários permitidos", + "placeholder": "IDs de usuário separados por vírgulas" + }, + "token": { + "label": "Token", + "placeholder": "Salvo pelo login via QR" + } + } + }, + "custom": { + "qrAlt": "QR code de login do WeChat", + "scanTitle": "Escaneie com o WeChat", + "scanDescription": "Escaneie com o WeChat no celular. O nanobot salva o estado localmente após o login.", + "waiting": "Aguardando leitura do WeChat...", + "connected": "WeChat está conectado.", + "stopped": "Login do WeChat interrompido.", + "connecting": "Conectando..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/vi.json b/nanobot/channels/weixin/webui/locales/vi.json new file mode 100644 index 00000000..d9d2b04e --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/vi.json @@ -0,0 +1,35 @@ +{ + "description": "Sử dụng nanobot từ các cuộc trò chuyện WeChat.", + "requirements": "Cài đặt kênh WeChat và gateway", + "setup": { + "primaryAction": "Kết nối WeChat", + "docsLabel": "Mở hướng dẫn WeChat", + "officialLabel": "Mở WeChat", + "tryIt": "Sau khi đăng nhập QR, gửi tin nhắn riêng đến tài khoản đã kết nối.", + "summary": "WeChat đăng nhập bằng mã QR và lưu trạng thái tài khoản cục bộ.", + "steps": [ + "Nhấn Kết nối và quét QR bằng WeChat.", + "Giữ gateway cục bộ chạy khi WeChat nhận tin nhắn.", + "Gửi tin nhắn riêng thử để xác nhận kết nối." + ], + "fields": { + "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" + }, + "token": { + "label": "Token", + "placeholder": "Được lưu khi đăng nhập QR" + } + } + }, + "custom": { + "qrAlt": "Mã QR đăng nhập WeChat", + "scanTitle": "Quét bằng WeChat", + "scanDescription": "Quét bằng WeChat trên điện thoại. nanobot lưu trạng thái cục bộ sau khi đăng nhập.", + "waiting": "Đang chờ quét WeChat...", + "connected": "WeChat đã kết nối.", + "stopped": "Đăng nhập WeChat đã dừng.", + "connecting": "Đang kết nối..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/zh-CN.json b/nanobot/channels/weixin/webui/locales/zh-CN.json new file mode 100644 index 00000000..78c5f38f --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/zh-CN.json @@ -0,0 +1,36 @@ +{ + "displayName": "微信", + "description": "在微信会话中使用 nanobot。", + "requirements": "微信渠道配置和网关", + "setup": { + "primaryAction": "连接微信", + "docsLabel": "打开微信配置指南", + "officialLabel": "打开微信", + "tryIt": "二维码登录完成后,向已连接的账户发送一条微信私信。", + "summary": "微信通过二维码登录,并将账户状态保存在本地。", + "steps": [ + "点击连接,用手机微信扫描二维码。", + "微信接收消息期间请保持本地网关运行。", + "发送一条私信测试,确认账户已连接。" + ], + "fields": { + "allowFrom": { + "label": "允许的用户", + "placeholder": "用户 ID,用逗号分隔" + }, + "token": { + "label": "令牌", + "placeholder": "二维码登录后自动保存" + } + } + }, + "custom": { + "qrAlt": "微信登录二维码", + "scanTitle": "使用微信扫码", + "scanDescription": "用手机微信扫描此二维码。登录后,nanobot 会将账户状态保存在本地。", + "waiting": "正在等待微信扫码...", + "connected": "微信已连接。", + "stopped": "微信登录已停止。", + "connecting": "正在连接..." + } +} diff --git a/nanobot/channels/weixin/webui/locales/zh-TW.json b/nanobot/channels/weixin/webui/locales/zh-TW.json new file mode 100644 index 00000000..ca18c35a --- /dev/null +++ b/nanobot/channels/weixin/webui/locales/zh-TW.json @@ -0,0 +1,36 @@ +{ + "displayName": "微信", + "description": "在微信對話中使用 nanobot。", + "requirements": "微信渠道設定和閘道", + "setup": { + "primaryAction": "連接微信", + "docsLabel": "開啟微信設定指南", + "officialLabel": "開啟微信", + "tryIt": "二維碼登入完成後,向已連接的帳戶傳送一則微信私訊。", + "summary": "微信透過二維碼登入,並將帳戶狀態儲存在本機。", + "steps": [ + "點擊連接,用手機微信掃描二維碼。", + "微信接收訊息期間請保持本機閘道執行。", + "傳送一則私訊測試,確認帳戶已連接。" + ], + "fields": { + "allowFrom": { + "label": "允許的使用者", + "placeholder": "使用者 ID,以逗號分隔" + }, + "token": { + "label": "權杖", + "placeholder": "二維碼登入後自動儲存" + } + } + }, + "custom": { + "qrAlt": "微信登入二維碼", + "scanTitle": "使用微信掃碼", + "scanDescription": "用手機微信掃描此二維碼。登入後,nanobot 會將帳戶狀態儲存在本機。", + "waiting": "正在等待微信掃碼...", + "connected": "微信已連接。", + "stopped": "微信登入已停止。", + "connecting": "正在連接..." + } +} diff --git a/nanobot/channels/whatsapp/__init__.py b/nanobot/channels/whatsapp/__init__.py new file mode 100644 index 00000000..0a5d5ce4 --- /dev/null +++ b/nanobot/channels/whatsapp/__init__.py @@ -0,0 +1 @@ +"""WhatsApp channel package.""" diff --git a/nanobot/channels/whatsapp/manifest.py b/nanobot/channels/whatsapp/manifest.py new file mode 100644 index 00000000..e874804e --- /dev/null +++ b/nanobot/channels/whatsapp/manifest.py @@ -0,0 +1,35 @@ +"""WhatsApp management contract.""" + +from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field +from nanobot.channels.contracts import ChannelManagementSpec, ChannelSetupSpec +from nanobot.channels.plugin import ChannelPlugin +from nanobot.channels.whatsapp.state import local_state_present +from nanobot.channels.whatsapp.validation import validate + +SETUP_SPEC = ChannelSetupSpec( + fields={ + "allowFrom": field("list", snapshot=False), + "groupPolicy": field( + "enum", + choices=DIRECT_GROUP_POLICIES, + default="open", + snapshot=False, + ), + "databasePath": field(writable=False, snapshot=False), + }, + official_url="https://faq.whatsapp.com/", + validator=validate, +) + +PLUGIN = ChannelPlugin( + name="whatsapp", + display_name="WhatsApp", + runtime=f"{__package__}.runtime:WhatsAppChannel", + setup=SETUP_SPEC, + management=ChannelManagementSpec(local_state_present=local_state_present), + dependencies=( + "neonize>=0.3.18.post0,<0.4.0", + "segno>=1.6.1,<2.0.0", + ), + webui="webui/index.ts", +) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp/runtime.py similarity index 100% rename from nanobot/channels/whatsapp.py rename to nanobot/channels/whatsapp/runtime.py diff --git a/nanobot/channels/whatsapp/state.py b/nanobot/channels/whatsapp/state.py new file mode 100644 index 00000000..fbfca5a8 --- /dev/null +++ b/nanobot/channels/whatsapp/state.py @@ -0,0 +1,25 @@ +"""WhatsApp-owned persisted login-state detection.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from nanobot.channels.contracts import channel_field_value +from nanobot.config.loader import get_config_path + + +def local_state_present(section: Any) -> bool: + configured_path = channel_field_value(section, "databasePath") + database_path = ( + Path(str(configured_path)).expanduser() + if configured_path + else get_config_path().parent / "whatsapp-auth" / "neonize.db" + ) + try: + return database_path.is_file() and database_path.stat().st_size > 0 + except OSError: + return False + + +__all__ = ["local_state_present"] diff --git a/nanobot/channels/whatsapp/tests/__init__.py b/nanobot/channels/whatsapp/tests/__init__.py new file mode 100644 index 00000000..1cc1a4cd --- /dev/null +++ b/nanobot/channels/whatsapp/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the WhatsApp channel package.""" diff --git a/tests/channels/test_whatsapp_channel.py b/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py similarity index 98% rename from tests/channels/test_whatsapp_channel.py rename to nanobot/channels/whatsapp/tests/test_whatsapp_channel.py index 8da16732..86dd0668 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/nanobot/channels/whatsapp/tests/test_whatsapp_channel.py @@ -8,10 +8,14 @@ from unittest.mock import AsyncMock, MagicMock import pytest +import nanobot.channels.whatsapp.runtime as whatsapp_module from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus -from nanobot.channels import whatsapp as whatsapp_module -from nanobot.channels.whatsapp import WhatsAppChannel, _legacy_bridge_config_fields, _NeonizeAPI +from nanobot.channels.whatsapp.runtime import ( + WhatsAppChannel, + _legacy_bridge_config_fields, + _NeonizeAPI, +) class _Proto: diff --git a/nanobot/channels/whatsapp/validation.py b/nanobot/channels/whatsapp/validation.py new file mode 100644 index 00000000..13c82c9e --- /dev/null +++ b/nanobot/channels/whatsapp/validation.py @@ -0,0 +1,34 @@ +"""WhatsApp setup validation owned by the channel package.""" + +from typing import Any + +from nanobot.channels.contracts import ChannelValidationContext +from nanobot.channels.validation import check, enabled, official_action, payload, string_value + + +def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: + checks: list[dict[str, Any]] = [] + if enabled(values) or string_value(values.get("databasePath")): + checks.append( + check("local_state", "Local login state", "pass", "Saved local login state was detected.") + ) + return payload("whatsapp", "configured", checks, can_enable=True) + checks.append( + check( + "terminal_login", + "Terminal login", + "skipped", + "This channel uses a terminal QR login flow.", + action_url=official_action("whatsapp"), + ) + ) + return payload( + "whatsapp", + "needs_setup", + checks, + missing_fields=["terminal_login"], + can_enable=False, + ) + + +__all__ = ["validate"] diff --git a/nanobot/channels/whatsapp/webui/index.ts b/nanobot/channels/whatsapp/webui/index.ts new file mode 100644 index 00000000..fd5ccfc8 --- /dev/null +++ b/nanobot/channels/whatsapp/webui/index.ts @@ -0,0 +1,20 @@ +import type { ChannelUiContribution } from "@/channel-plugins/types"; +import { chatAppGuideUrl } from "@/components/settings/channels/catalog"; + +export default { + presentation: { + displayName: "WhatsApp", + initials: "WA", + color: "#25D366", + logoUrl: "https://www.whatsapp.com/favicon.ico", + setup: { + mode: "connect", + command: "nanobot channels login whatsapp", + docsUrl: chatAppGuideUrl("whatsapp"), + manualFields: [ + { key: "channels.whatsapp.allowFrom" }, + { key: "channels.whatsapp.groupPolicy" }, + ], + }, + }, +} satisfies ChannelUiContribution; diff --git a/nanobot/channels/whatsapp/webui/locales/en.json b/nanobot/channels/whatsapp/webui/locales/en.json new file mode 100644 index 00000000..34fbf56a --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/en.json @@ -0,0 +1,30 @@ +{ + "description": "Use nanobot from WhatsApp conversations.", + "requirements": "WhatsApp connection setup and gateway", + "setup": { + "primaryAction": "Connect WhatsApp", + "docsLabel": "Open WhatsApp setup", + "officialLabel": "Open WhatsApp help", + "tryIt": "After terminal login finishes, send a WhatsApp DM to the connected account.", + "summary": "WhatsApp is connected by scanning a QR code from the account that should run the bot.", + "steps": [ + "Run the WhatsApp login command shown below.", + "Scan the QR code in the terminal with WhatsApp on your phone.", + "Return here after login, enable WhatsApp, then send a direct test message." + ], + "fields": { + "allowFrom": { + "label": "Allowed contacts", + "placeholder": "Phone numbers or WhatsApp IDs" + }, + "groupPolicy": { + "label": "Group behavior", + "choices": { + "mention": "Mention only", + "open": "All messages", + "allowlist": "Allowlist" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/es.json b/nanobot/channels/whatsapp/webui/locales/es.json new file mode 100644 index 00000000..c3ae6bc7 --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/es.json @@ -0,0 +1,30 @@ +{ + "description": "Usa nanobot desde conversaciones de WhatsApp.", + "requirements": "Configuración de conexión de WhatsApp y gateway", + "setup": { + "primaryAction": "Conectar WhatsApp", + "docsLabel": "Abrir guía de WhatsApp", + "officialLabel": "Abrir la ayuda de WhatsApp", + "tryIt": "Tras iniciar sesión en la terminal, envía un DM a la cuenta conectada.", + "summary": "WhatsApp se conecta escaneando un QR con la cuenta que ejecutará el bot.", + "steps": [ + "Ejecuta el comando de inicio de WhatsApp mostrado abajo.", + "Escanea el QR de la terminal con WhatsApp en tu teléfono.", + "Vuelve aquí tras iniciar sesión, activa WhatsApp y envía un mensaje directo de prueba." + ], + "fields": { + "allowFrom": { + "label": "Contactos permitidos", + "placeholder": "Números o ID de WhatsApp" + }, + "groupPolicy": { + "label": "Comportamiento en grupos", + "choices": { + "mention": "Solo menciones", + "open": "Todos los mensajes", + "allowlist": "Lista permitida" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/fr.json b/nanobot/channels/whatsapp/webui/locales/fr.json new file mode 100644 index 00000000..d6c20e10 --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/fr.json @@ -0,0 +1,30 @@ +{ + "description": "Utilisez nanobot depuis les conversations WhatsApp.", + "requirements": "Configuration de la connexion WhatsApp et passerelle", + "setup": { + "primaryAction": "Connecter WhatsApp", + "docsLabel": "Ouvrir le guide WhatsApp", + "officialLabel": "Ouvrir l’aide WhatsApp", + "tryIt": "Après la connexion dans le terminal, envoyez un message privé au compte connecté.", + "summary": "WhatsApp se connecte en scannant un QR code avec le compte qui exécutera le bot.", + "steps": [ + "Exécutez la commande de connexion WhatsApp ci-dessous.", + "Scannez le QR code du terminal avec WhatsApp sur votre téléphone.", + "Revenez ici après la connexion, activez WhatsApp et envoyez un message privé test." + ], + "fields": { + "allowFrom": { + "label": "Contacts autorisés", + "placeholder": "Numéros ou ID WhatsApp" + }, + "groupPolicy": { + "label": "Comportement en groupe", + "choices": { + "mention": "Mentions uniquement", + "open": "Tous les messages", + "allowlist": "Liste d’autorisation" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/id.json b/nanobot/channels/whatsapp/webui/locales/id.json new file mode 100644 index 00000000..b8cefec9 --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/id.json @@ -0,0 +1,30 @@ +{ + "description": "Gunakan nanobot dari percakapan WhatsApp.", + "requirements": "Setup koneksi WhatsApp dan gateway", + "setup": { + "primaryAction": "Hubungkan WhatsApp", + "docsLabel": "Buka panduan WhatsApp", + "officialLabel": "Buka bantuan WhatsApp", + "tryIt": "Setelah login terminal selesai, kirim DM ke akun yang terhubung.", + "summary": "WhatsApp terhubung dengan memindai kode QR dari akun yang akan menjalankan bot.", + "steps": [ + "Jalankan perintah login WhatsApp yang ditampilkan di bawah.", + "Pindai kode QR di terminal dengan WhatsApp di ponsel.", + "Kembali ke sini setelah login, aktifkan WhatsApp, lalu kirim pesan langsung uji." + ], + "fields": { + "allowFrom": { + "label": "Kontak yang diizinkan", + "placeholder": "Nomor telepon atau ID WhatsApp" + }, + "groupPolicy": { + "label": "Perilaku grup", + "choices": { + "mention": "Hanya sebutan", + "open": "Semua pesan", + "allowlist": "Daftar izin" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/ja.json b/nanobot/channels/whatsapp/webui/locales/ja.json new file mode 100644 index 00000000..7c04b3ea --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/ja.json @@ -0,0 +1,30 @@ +{ + "description": "WhatsApp の会話から nanobot を利用します。", + "requirements": "WhatsApp 接続設定とゲートウェイ", + "setup": { + "primaryAction": "WhatsApp に接続", + "docsLabel": "WhatsApp 設定ガイドを開く", + "officialLabel": "WhatsApp ヘルプを開く", + "tryIt": "ターミナルでのログイン後、接続したアカウントに WhatsApp の DM を送信します。", + "summary": "ボットを動かす WhatsApp アカウントで QR コードを読み取って接続します。", + "steps": [ + "下に表示された WhatsApp ログインコマンドを実行します。", + "スマートフォンの WhatsApp でターミナルの QR コードを読み取ります。", + "ログイン後に戻り、WhatsApp を有効にして DM でテストします。" + ], + "fields": { + "allowFrom": { + "label": "許可する連絡先", + "placeholder": "電話番号または WhatsApp ID" + }, + "groupPolicy": { + "label": "グループでの動作", + "choices": { + "mention": "メンションのみ", + "open": "すべてのメッセージ", + "allowlist": "許可リスト" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/ko.json b/nanobot/channels/whatsapp/webui/locales/ko.json new file mode 100644 index 00000000..431dbc39 --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/ko.json @@ -0,0 +1,30 @@ +{ + "description": "WhatsApp 대화에서 nanobot을 사용합니다.", + "requirements": "WhatsApp 연결 설정 및 게이트웨이", + "setup": { + "primaryAction": "WhatsApp 연결", + "docsLabel": "WhatsApp 설정 가이드 열기", + "officialLabel": "WhatsApp 도움말 열기", + "tryIt": "터미널 로그인이 끝나면 연결된 계정으로 WhatsApp DM을 보내세요.", + "summary": "봇을 실행할 WhatsApp 계정으로 QR 코드를 스캔해 연결합니다.", + "steps": [ + "아래 표시된 WhatsApp 로그인 명령을 실행하세요.", + "휴대폰 WhatsApp으로 터미널의 QR 코드를 스캔하세요.", + "로그인 후 여기로 돌아와 WhatsApp을 활성화하고 DM으로 테스트하세요." + ], + "fields": { + "allowFrom": { + "label": "허용된 연락처", + "placeholder": "전화번호 또는 WhatsApp ID" + }, + "groupPolicy": { + "label": "그룹 동작", + "choices": { + "mention": "멘션만", + "open": "모든 메시지", + "allowlist": "허용 목록" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/pt-BR.json b/nanobot/channels/whatsapp/webui/locales/pt-BR.json new file mode 100644 index 00000000..cde9c5da --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/pt-BR.json @@ -0,0 +1,30 @@ +{ + "description": "Use o nanobot em conversas do WhatsApp.", + "requirements": "Configuração da conexão do WhatsApp e gateway", + "setup": { + "primaryAction": "Conectar WhatsApp", + "docsLabel": "Abrir guia do WhatsApp", + "officialLabel": "Abrir ajuda do WhatsApp", + "tryIt": "Após o login no terminal, envie uma DM à conta conectada.", + "summary": "O WhatsApp conecta ao escanear um QR code com a conta que executará o bot.", + "steps": [ + "Execute o comando de login do WhatsApp mostrado abaixo.", + "Escaneie o QR code do terminal com o WhatsApp no celular.", + "Volte aqui após o login, ative o WhatsApp e envie uma mensagem direta de teste." + ], + "fields": { + "allowFrom": { + "label": "Contatos permitidos", + "placeholder": "Números ou IDs do WhatsApp" + }, + "groupPolicy": { + "label": "Comportamento em grupos", + "choices": { + "mention": "Somente menções", + "open": "Todas as mensagens", + "allowlist": "Lista de permissão" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/vi.json b/nanobot/channels/whatsapp/webui/locales/vi.json new file mode 100644 index 00000000..1018123d --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/vi.json @@ -0,0 +1,30 @@ +{ + "description": "Sử dụng nanobot từ các cuộc trò chuyện WhatsApp.", + "requirements": "Cài đặt kết nối WhatsApp và gateway", + "setup": { + "primaryAction": "Kết nối WhatsApp", + "docsLabel": "Mở hướng dẫn WhatsApp", + "officialLabel": "Mở trợ giúp WhatsApp", + "tryIt": "Sau khi đăng nhập terminal, gửi tin nhắn riêng đến tài khoản đã kết nối.", + "summary": "WhatsApp kết nối bằng cách quét mã QR từ tài khoản sẽ chạy bot.", + "steps": [ + "Chạy lệnh đăng nhập WhatsApp hiển thị bên dưới.", + "Quét mã QR trong terminal bằng WhatsApp trên điện thoại.", + "Sau khi đăng nhập, quay lại đây, bật WhatsApp và gửi tin nhắn riêng thử." + ], + "fields": { + "allowFrom": { + "label": "Liên hệ được phép", + "placeholder": "Số điện thoại hoặc ID WhatsApp" + }, + "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" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/zh-CN.json b/nanobot/channels/whatsapp/webui/locales/zh-CN.json new file mode 100644 index 00000000..7883c931 --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/zh-CN.json @@ -0,0 +1,30 @@ +{ + "description": "在 WhatsApp 会话中使用 nanobot。", + "requirements": "WhatsApp 连接配置和网关", + "setup": { + "primaryAction": "连接 WhatsApp", + "docsLabel": "打开 WhatsApp 配置指南", + "officialLabel": "打开 WhatsApp 帮助", + "tryIt": "终端登录完成后,向已连接的账户发送一条 WhatsApp 私信。", + "summary": "使用要运行机器人的 WhatsApp 账户扫描二维码即可完成连接。", + "steps": [ + "运行下方显示的 WhatsApp 登录命令。", + "用手机 WhatsApp 扫描终端中的二维码。", + "登录后返回这里,启用 WhatsApp,然后发送一条私信测试。" + ], + "fields": { + "allowFrom": { + "label": "允许的联系人", + "placeholder": "电话号码或 WhatsApp ID" + }, + "groupPolicy": { + "label": "群组行为", + "choices": { + "mention": "仅提及时", + "open": "所有消息", + "allowlist": "白名单" + } + } + } + } +} diff --git a/nanobot/channels/whatsapp/webui/locales/zh-TW.json b/nanobot/channels/whatsapp/webui/locales/zh-TW.json new file mode 100644 index 00000000..0476d9f8 --- /dev/null +++ b/nanobot/channels/whatsapp/webui/locales/zh-TW.json @@ -0,0 +1,30 @@ +{ + "description": "在 WhatsApp 對話中使用 nanobot。", + "requirements": "WhatsApp 連接設定和閘道", + "setup": { + "primaryAction": "連接 WhatsApp", + "docsLabel": "開啟 WhatsApp 設定指南", + "officialLabel": "開啟 WhatsApp 說明", + "tryIt": "終端機登入完成後,向已連接的帳戶傳送一則 WhatsApp 私訊。", + "summary": "使用要執行機器人的 WhatsApp 帳戶掃描二維碼即可完成連接。", + "steps": [ + "執行下方顯示的 WhatsApp 登入指令。", + "用手機 WhatsApp 掃描終端機中的二維碼。", + "登入後返回這裡,啟用 WhatsApp,然後傳送一則私訊測試。" + ], + "fields": { + "allowFrom": { + "label": "允許的聯絡人", + "placeholder": "電話號碼或 WhatsApp ID" + }, + "groupPolicy": { + "label": "群組行為", + "choices": { + "mention": "僅提及時", + "open": "所有訊息", + "allowlist": "允許清單" + } + } + } + } +} diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f480a385..6cdf49c4 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -728,22 +728,24 @@ def _onboard_plugins(config_path: Path) -> None: """Inject default config for all discovered channels (built-in + plugins).""" import json - from nanobot.channels.registry import discover_all + from nanobot.channels.contracts import channel_default_config + from nanobot.channels.registry import discover_plugins from nanobot.config.loader import merge_missing_defaults - all_channels = discover_all() - if not all_channels: + plugins = discover_plugins() + if not plugins: return with open(config_path, encoding="utf-8") as f: data = json.load(f) channels = data.setdefault("channels", {}) - for name, cls in all_channels.items(): + for name, plugin in plugins.items(): + defaults = channel_default_config(plugin) if name not in channels: - channels[name] = cls.default_config() + channels[name] = defaults else: - channels[name] = merge_missing_defaults(channels[name], cls.default_config()) + channels[name] = merge_missing_defaults(channels[name], defaults) with open(config_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) @@ -751,8 +753,7 @@ def _onboard_plugins(config_path: Path) -> None: def _print_enable_options( extras: dict[str, list[str] | None], - builtin_channels: set[str], - plugin_channels: dict[str, Any], + channel_plugins: dict[str, Any], config: Config, ) -> None: table = Table(title="Available Features") @@ -760,10 +761,16 @@ def _print_enable_options( table.add_column("Type") table.add_column("Enabled") - for item in sorted(builtin_channels | set(plugin_channels) | set(extras)): - is_channel = item in builtin_channels or item in plugin_channels + for item in sorted(set(channel_plugins) | set(extras)): + plugin = channel_plugins.get(item) + is_channel = plugin is not None enabled = ( - feature_support.channel_enabled(config, item) + feature_support.channel_enabled( + config, + item, + plugin, + default_enabled=plugin.default_enabled, + ) if is_channel else feature_support.extra_installed(item, extras[item]) ) @@ -931,7 +938,7 @@ def _provider_setup_error(config: Config) -> str | None: def _webui_config_dict(config: Config) -> dict[str, Any]: """Return the current WebSocket config as a mutable alias-key dictionary.""" - from nanobot.channels.websocket import WebSocketConfig + from nanobot.channels.websocket.runtime import WebSocketConfig current = getattr(config.channels, "websocket", None) or {} model = WebSocketConfig.model_validate(current) @@ -939,7 +946,7 @@ def _webui_config_dict(config: Config) -> dict[str, Any]: def _webui_channel_enabled(config: Config) -> bool: - from nanobot.channels.websocket import WebSocketConfig + from nanobot.channels.websocket.runtime import WebSocketConfig current = getattr(config.channels, "websocket", None) or {} return bool(WebSocketConfig.model_validate(current).enabled) @@ -1044,7 +1051,7 @@ def _webui_display_url(url: str) -> str: def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> tuple[bool, bool]: """Enable the local WebUI channel with safe localhost defaults.""" - from nanobot.channels.websocket import WebSocketConfig + from nanobot.channels.websocket.runtime import WebSocketConfig current = getattr(config.channels, "websocket", None) or {} model = WebSocketConfig.model_validate(current) @@ -2515,7 +2522,7 @@ def plugins_list( config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), ): """List optional nanobot features.""" - from nanobot.channels.registry import discover_channel_names, discover_plugins + from nanobot.channels.registry import discover_plugins from nanobot.config.loader import load_config, set_config_path resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None @@ -2524,7 +2531,6 @@ def plugins_list( _print_enable_options( feature_support.optional_dependency_groups(), - set(discover_channel_names()), discover_plugins(), load_config(resolved_config_path), ) diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index 0fc07f4d..abc6c53e 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -1272,7 +1272,7 @@ def _get_channel_info() -> dict[str, tuple[str, type[BaseModel]]]: result: dict[str, tuple[str, type[BaseModel]]] = {} for name, channel_cls in discover_all().items(): try: - mod = importlib.import_module(f"nanobot.channels.{name}") + mod = importlib.import_module(channel_cls.__module__) config_name = channel_cls.__name__.replace("Channel", "Config") config_cls = getattr(mod, config_name, None) if config_cls and isinstance(config_cls, type) and issubclass(config_cls, BaseModel): diff --git a/nanobot/optional_features.py b/nanobot/optional_features.py index c9aa700d..b70c415d 100644 --- a/nanobot/optional_features.py +++ b/nanobot/optional_features.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import subprocess import sys -from contextlib import suppress from dataclasses import dataclass from importlib.metadata import PackageNotFoundError, distribution from pathlib import Path @@ -14,19 +13,20 @@ from loguru import logger from packaging.requirements import Requirement from packaging.utils import canonicalize_name -from nanobot.channels._feishu_instances import ( - DEFAULT_INSTANCE_ID, - feishu_instance_specs, - set_feishu_instance_enabled, -) -from nanobot.channels._setup import ( +from nanobot.channels._setup import channel_setup_spec +from nanobot.channels.contracts import ( + ChannelSetupSpec, + channel_feature_instances, channel_field_value, - channel_setup_spec, + channel_instance_specs, + channel_local_state_present, + channel_set_config_enabled, channel_value_present, + refresh_channel_feature_metadata, + resolve_channel_action_target, stringify_channel_value, ) -from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS -from nanobot.config.loader import merge_missing_defaults +from nanobot.channels.registry import channel_default_enabled from nanobot.config.schema import Config @@ -279,80 +279,68 @@ def write_config_data(path: Path, data: dict[str, Any]) -> None: json.dump(data, f, indent=2, ensure_ascii=False) -def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None: +def set_channel_config_enabled( + config_path: Path, + channel_name: str, + plugin: Any, + enabled: bool, + *, + instance_id: str | None = "default", +) -> None: + """Persist one instance, or the top-level plugin gate when the target is ``None``.""" data = read_config_data(config_path) channels = data.setdefault("channels", {}) existing = channels.get(channel_name, {}) if not isinstance(existing, dict): existing = {} - merged = merge_missing_defaults(existing, defaults) - merged["enabled"] = True - channels[channel_name] = merged + if instance_id is None: + existing["enabled"] = enabled + channels[channel_name] = existing + else: + try: + channels[channel_name] = channel_set_config_enabled( + plugin, + existing, + enabled, + instance_id=instance_id, + ) + except ValueError as exc: + raise OptionalFeatureError( + f"Invalid {channel_name} configuration: {exc}", + status=400, + ) from exc write_config_data(config_path, data) -def enable_feishu_instance_config( - config_path: Path, - defaults: dict[str, Any], +def channel_enabled( + config: Config, + name: str, + plugin: Any | None = None, *, - instance_id: str = DEFAULT_INSTANCE_ID, -) -> None: - data = read_config_data(config_path) - channels = data.setdefault("channels", {}) - existing = channels.get("feishu", {}) - if not isinstance(existing, dict): - existing = {} - channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, True) - write_config_data(config_path, data) - - -def disable_channel_config(config_path: Path, channel_name: str) -> None: - data = read_config_data(config_path) - channels = data.setdefault("channels", {}) - existing = channels.get(channel_name, {}) - if not isinstance(existing, dict): - existing = {} - existing["enabled"] = False - channels[channel_name] = existing - write_config_data(config_path, data) - - -def disable_feishu_instance_config( - config_path: Path, - defaults: dict[str, Any], - *, - instance_id: str = DEFAULT_INSTANCE_ID, -) -> None: - data = read_config_data(config_path) - channels = data.setdefault("channels", {}) - existing = channels.get("feishu", {}) - if not isinstance(existing, dict): - existing = {} - channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, False) - write_config_data(config_path, data) - - -def channel_enabled(config: Config, name: str) -> bool: + default_enabled: bool | None = None, +) -> bool: section = getattr(config.channels, name, None) - if name == "feishu": - from nanobot.channels.feishu import FeishuChannel - - return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True)) - default_enabled = name in DEFAULT_ENABLED_CHANNELS + if default_enabled is None: + default_enabled = plugin.default_enabled if plugin is not None else channel_default_enabled(name) if section is None: return default_enabled - if isinstance(section, dict): - return bool(section.get("enabled", default_enabled)) - return bool(getattr(section, "enabled", default_enabled)) + if plugin is None: + from nanobot.channels.registry import load_channel_plugin + + plugin = load_channel_plugin(name) + return bool(channel_instance_specs(plugin, section, enabled_only=True)) -def _channel_config_snapshot(section: Any, name: str) -> tuple[dict[str, str], list[str]]: +def _channel_config_snapshot( + section: Any, + name: str, + spec: ChannelSetupSpec | None, +) -> tuple[dict[str, str], list[str]]: if hasattr(section, "model_dump"): section = section.model_dump(mode="json", by_alias=True) if not isinstance(section, dict): return {}, [] - spec = channel_setup_spec(name) if spec is None: return {}, [] @@ -370,71 +358,58 @@ def _channel_config_snapshot(section: Any, name: str) -> tuple[dict[str, str], l return values, configured_fields -def _channel_has_required_setup(section: Any, name: str) -> bool: - spec = channel_setup_spec(name) +def _channel_has_required_setup(section: Any, spec: ChannelSetupSpec | None) -> bool: return bool(spec and spec.is_configured(section)) -def _local_login_state_present(section: Any, name: str) -> bool: - """Return whether a QR-login channel has reusable local account state.""" - from nanobot.config.loader import get_config_path - - if name == "weixin": - configured_dir = channel_field_value(section, "stateDir") - state_dir = ( - Path(str(configured_dir)).expanduser() - if configured_dir - else get_config_path().parent / "weixin" - ) - try: - payload = json.loads((state_dir / "account.json").read_text(encoding="utf-8")) - except (OSError, ValueError, TypeError): - return False - return bool(str(payload.get("token") or "").strip()) - - if name == "whatsapp": - configured_path = channel_field_value(section, "databasePath") - database_path = ( - Path(str(configured_path)).expanduser() - if configured_path - else get_config_path().parent / "whatsapp-auth" / "neonize.db" - ) - try: - return database_path.is_file() and database_path.stat().st_size > 0 - except OSError: - return False - - return False - - -def _feishu_instance_display_name(config: dict[str, Any]) -> str: - display_name = str(config.get("displayName") or "").strip() - if display_name: - return display_name - local_name = str(config.get("name") or "").strip() - return local_name or "nanobot" - - -def channel_configured(config: Config, name: str) -> bool: +def channel_configured( + config: Config, + name: str, + spec: ChannelSetupSpec | None = None, + plugin: Any | None = None, + *, + default_enabled: bool | None = None, +) -> bool: """Return whether a channel has enough saved setup to be enabled directly.""" section = getattr(config.channels, name, None) - if name in {"weixin", "whatsapp"} and _local_login_state_present(section, name): + if plugin is None: + from nanobot.channels.registry import load_channel_plugin + + plugin = load_channel_plugin(name) + + if channel_local_state_present(plugin, section): return True if section is None: return False - if name == "feishu": - from nanobot.channels.feishu import FeishuChannel - + if plugin.management.multi_instance: return any( - _channel_has_required_setup(instance.config, "feishu") - for instance in feishu_instance_specs(section, FeishuChannel.default_config()) + _channel_has_required_setup(instance.config, spec) + for instance in channel_instance_specs( + plugin, + section, + enabled_only=False, + ) ) - spec = channel_setup_spec(name) if not spec or not spec.required: - return channel_enabled(config, name) - return _channel_has_required_setup(section, name) + return channel_enabled( + config, + name, + plugin, + default_enabled=default_enabled, + ) + return _channel_has_required_setup(section, spec) + + +def _feature_dependencies( + name: str, + channel_plugin: Any | None, + extras: dict[str, list[str] | None], +) -> list[str] | None: + if channel_plugin is not None: + return list(channel_plugin.dependencies) + return extras.get(name) def optional_features_payload( @@ -442,72 +417,100 @@ def optional_features_payload( config: Config | None = None, last_action: dict[str, Any] | None = None, ) -> dict[str, Any]: - from nanobot.channels.registry import discover_channel_names, discover_plugins + from nanobot.channels.registry import discover_plugins from nanobot.config.loader import load_config - config_provided = config is not None config = config or load_config() - if not config_provided: - with suppress(Exception): - from nanobot.channels.feishu import refresh_saved_feishu_identities - - if refresh_saved_feishu_identities(config): - config = load_config() extras = optional_dependency_groups() - builtin_channels = set(discover_channel_names()) - plugin_channels = discover_plugins() + channel_plugins = discover_plugins() features: list[dict[str, Any]] = [] - for name in sorted(builtin_channels | set(plugin_channels) | set(extras)): - is_channel = name in builtin_channels or name in plugin_channels - installed = extra_installed(name, extras[name]) if name in extras else True - enabled = channel_enabled(config, name) if is_channel else installed - configured = channel_configured(config, name) if is_channel else installed - ready = bool(enabled and installed) - status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled" + feature_names = set(channel_plugins) | set(extras) + for name in sorted(feature_names): + channel_plugin = channel_plugins.get(name) + is_channel = channel_plugin is not None + dependencies = _feature_dependencies(name, channel_plugin, extras) + has_dependencies = bool(dependencies) + installed = extra_installed(name, dependencies) if has_dependencies else True feature = { "name": name, - "display_name": name.replace("_", " ").title(), + "display_name": ( + channel_plugin.display_name + if channel_plugin is not None + else name.replace("_", " ").title() + ), "type": "channel" if is_channel else "feature", - "enabled": enabled, - "configured": configured, "installed": installed, - "ready": ready, - "status": status, - "install_supported": name in extras or is_channel, + "install_supported": has_dependencies or is_channel, "requires_restart": _feature_requires_restart(name, is_channel=is_channel), } - if is_channel: + if channel_plugin is not None: + feature["capabilities"] = sorted(channel_plugin.capabilities) + feature["settings_visible"] = channel_plugin.settings_visible + if channel_plugin.webui is not None: + feature["webui"] = channel_plugin.webui + + if not is_channel: + feature.update({ + "enabled": installed, + "configured": installed, + "ready": installed, + "status": "enabled" if installed else "missing_dependency", + }) + features.append(feature) + continue + + try: + assert channel_plugin is not None + setup_spec = channel_setup_spec(name, plugin=channel_plugin) + if setup_spec is not None: + feature["setup"] = setup_spec.to_public_dict(name) + enabled = channel_enabled( + config, + name, + channel_plugin, + default_enabled=channel_plugin.default_enabled, + ) + configured = channel_configured( + config, + name, + setup_spec, + channel_plugin, + default_enabled=channel_plugin.default_enabled, + ) + ready = bool(enabled and installed) + status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled" + feature.update({ + "enabled": enabled, + "configured": configured, + "ready": ready, + "status": status, + }) config_values, configured_fields = _channel_config_snapshot( getattr(config.channels, name, None), name, + setup_spec, ) if config_values: feature["config_values"] = config_values if configured_fields: feature["configured_fields"] = configured_fields - if name == "feishu" and is_channel: - from nanobot.channels.feishu import FeishuChannel - - specs = feishu_instance_specs( - getattr(config.channels, "feishu", None), - FeishuChannel.default_config(), + instances = channel_feature_instances( + channel_plugin, + getattr(config.channels, name, None), + setup_spec=setup_spec, ) - feature["instances"] = [ - { - "id": spec.instance_id, - "name": spec.config.get("name") or "nanobot", - "display_name": _feishu_instance_display_name(spec.config), - "avatar_url": spec.config.get("avatarUrl") or "", - "domain": spec.config.get("domain") or "feishu", - "enabled": bool(spec.config.get("enabled", False)), - "configured": _channel_has_required_setup(spec.config, "feishu"), - "app_id": spec.config.get("appId") or spec.config.get("app_id") or "", - "group_policy": spec.config.get("groupPolicy") or "mention", - "allow_from": list(spec.config.get("allowFrom") or []), - } - for spec in specs - ] + if instances is not None: + feature["instances"] = instances + except Exception as exc: + logger.warning("Could not inspect {} channel configuration: {}", name, exc) + feature.update({ + "enabled": False, + "configured": False, + "ready": False, + "status": "invalid_config", + "error": "Channel configuration could not be inspected.", + }) features.append(feature) payload = { @@ -519,19 +522,117 @@ def optional_features_payload( return payload +def with_channel_runtime_status( + payload: dict[str, Any], + runtime_status: dict[str, Any], +) -> dict[str, Any]: + """Overlay live ChannelManager state on configuration-derived features.""" + statuses_by_owner: dict[str, list[dict[str, Any]]] = {} + for status in runtime_status.values(): + if not isinstance(status, dict): + continue + owner = status.get("owner") + if isinstance(owner, str): + statuses_by_owner.setdefault(owner, []).append(status) + + features: list[dict[str, Any]] = [] + for original in payload.get("features", []): + feature = dict(original) + if feature.get("type") != "channel": + features.append(feature) + continue + + desired_enabled = bool(feature.get("enabled")) + owner_statuses = statuses_by_owner.get(str(feature.get("name")), []) + if desired_enabled and not owner_statuses: + owner_statuses = [{ + "state": "failed", + "running": False, + "error": "Enabled channel has no runtime. Check gateway logs.", + }] + + instances = feature.get("instances") + if isinstance(instances, list): + by_instance = { + str(status.get("instance_id", "default")): status + for status in owner_statuses + } + decorated_instances = [] + for original_instance in instances: + instance = dict(original_instance) + desired_instance = bool(instance.get("enabled")) + status = by_instance.get(str(instance.get("id", "default"))) + if desired_instance and status is None: + status = { + "state": "failed", + "running": False, + "error": "Enabled channel instance has no runtime. Check gateway logs.", + } + owner_statuses.append(status) + state = str(status.get("state", "stopped")) if status else "stopped" + instance["runtime_status"] = state + instance["running"] = state == "running" + if status and status.get("error"): + instance["runtime_error"] = str(status["error"]) + decorated_instances.append(instance) + feature["instances"] = decorated_instances + + state = _combined_channel_runtime_state(owner_statuses, desired_enabled) + feature["runtime_status"] = state + feature["running"] = state == "running" + feature["ready"] = state == "running" + feature["status"] = "enabled" if state == "running" else state + error = next( + ( + str(status["error"]) + for status in owner_statuses + if status.get("error") + ), + None, + ) + if error: + feature["runtime_error"] = error + features.append(feature) + + decorated = dict(payload) + decorated["features"] = features + decorated["enabled_count"] = sum( + 1 + for feature in features + if ( + feature.get("running") + if feature.get("type") == "channel" + else feature.get("enabled") + ) + ) + return decorated + + +def _combined_channel_runtime_state( + statuses: list[dict[str, Any]], + desired_enabled: bool, +) -> str: + if not desired_enabled: + return "stopped" + states = {str(status.get("state", "stopped")) for status in statuses} + if "failed" in states: + return "failed" + if "running" in states: + return "running" + if "starting" in states: + return "starting" + return "stopped" + + def enable_optional_feature( name: str, *, config_path: Path | None = None, allow_install: bool = True, - instance_id: str = DEFAULT_INSTANCE_ID, + instance_id: str | None = None, runner: Any = run_install_command, ) -> dict[str, Any]: - from nanobot.channels.registry import ( - discover_channel_names, - discover_plugins, - load_channel_class, - ) + from nanobot.channels.registry import discover_plugins from nanobot.config.loader import get_config_path if name in _BUNDLED_FEATURE_ALIASES: @@ -545,15 +646,17 @@ def enable_optional_feature( payload["requires_restart"] = False return payload config_path = config_path or get_config_path() + requested_instance_id = (instance_id or "").strip() or None extras = optional_dependency_groups() - builtin_channels = set(discover_channel_names()) - plugin_channels = discover_plugins() - known = builtin_channels | set(plugin_channels) | set(extras) + channel_plugins = discover_plugins() + known = set(channel_plugins) | set(extras) if name not in known: available = ", ".join(sorted(known)) raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404) - if name in extras and not extra_installed(name, extras[name]): + channel_plugin = channel_plugins.get(name) + dependencies = _feature_dependencies(name, channel_plugin, extras) + if dependencies and not extra_installed(name, dependencies): if not allow_install: raise OptionalFeatureError( "Installing optional features from a remote WebUI is disabled. " @@ -562,7 +665,7 @@ def enable_optional_feature( ) result = install_extra( name, - extras[name], + dependencies, runner=runner, ) if not result.ok: @@ -570,33 +673,80 @@ def enable_optional_feature( detail = f": {result.output}" if result.output else "" raise OptionalFeatureError(f"Failed: {failed}{detail}", status=500) - if name in builtin_channels: + channel_cls: Any | None = None + target_instance_id: str | None = None + if channel_plugin is not None: try: - channel_cls = load_channel_class(name) + channel_cls = channel_plugin.load_channel_class() except Exception as exc: raise OptionalFeatureError( f"Channel '{name}' is not importable after enable: {exc}", status=500, ) from exc - if name == "feishu": - enable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id) - else: - enable_channel_config(config_path, name, channel_cls.default_config()) - message = f"Enabled channel '{name}'" - elif name in plugin_channels: - enable_channel_config(config_path, name, plugin_channels[name].default_config()) + target_instance_id = resolve_channel_action_target( + requested_instance_id, + ) + set_channel_config_enabled( + config_path, + name, + channel_plugin, + True, + instance_id=target_instance_id, + ) message = f"Enabled channel '{name}'" else: message = f"Enabled feature '{name}'" - payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True}) + if channel_cls is not None and target_instance_id is not None: + try: + refresh_channel_feature_metadata( + channel_cls, + config_path, + instance_id=target_instance_id, + ) + except Exception as exc: + logger.warning("Could not refresh {} channel metadata: {}", name, exc) + + from nanobot.config.loader import load_config + + payload = optional_features_payload( + config=load_config(config_path), + last_action={"ok": True, "message": message, "enabled": True}, + ) payload["requires_restart"] = _feature_requires_restart( name, - is_channel=name in builtin_channels or name in plugin_channels, + is_channel=channel_plugin is not None, ) return payload +def ensure_enabled_channel_dependencies( + enabled_names: set[str], + plugins: dict[str, Any], + *, + runner: Any = run_install_command, +) -> dict[str, str]: + """Install requirements declared by enabled channel manifests. + + Returns user-safe errors keyed by channel name. Detailed installer output + remains in gateway logs. + """ + failures: dict[str, str] = {} + for name in sorted(enabled_names): + plugin = plugins.get(name) + if plugin is None: + continue + dependencies = list(plugin.dependencies) + if not dependencies or extra_installed(name, dependencies): + continue + result = install_extra(name, dependencies, runner=runner) + if result.ok and extra_installed(name, dependencies): + continue + failures[name] = "Channel dependencies could not be installed. Check gateway logs." + logger.error("Could not prepare dependencies for enabled channel '{}'", name) + return failures + + def _feature_requires_restart(name: str, *, is_channel: bool) -> bool: """Return whether an installed feature needs the running engine rebuilt.""" if is_channel: @@ -609,30 +759,33 @@ def disable_optional_feature( name: str, *, config_path: Path | None = None, - instance_id: str = DEFAULT_INSTANCE_ID, + instance_id: str | None = None, ) -> dict[str, Any]: - from nanobot.channels.registry import discover_channel_names, discover_plugins - from nanobot.config.loader import get_config_path + from nanobot.channels.registry import discover_plugins + from nanobot.config.loader import get_config_path, load_config config_path = config_path or get_config_path() + requested_instance_id = (instance_id or "").strip() or None extras = optional_dependency_groups() - builtin_channels = set(discover_channel_names()) - plugin_channels = discover_plugins() - known_channels = builtin_channels | set(plugin_channels) + channel_plugins = discover_plugins() + known_channels = set(channel_plugins) known = known_channels | set(extras) if name not in known: available = ", ".join(sorted(known)) raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404) if name not in known_channels: raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400) - if name == "feishu": - from nanobot.channels.registry import load_channel_class - - channel_cls = load_channel_class(name) - disable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id) - else: - disable_channel_config(config_path, name) + channel_plugin = channel_plugins[name] + target_instance_id = resolve_channel_action_target(requested_instance_id) + set_channel_config_enabled( + config_path, + name, + channel_plugin, + False, + instance_id=target_instance_id, + ) payload = optional_features_payload( + config=load_config(config_path), last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False} ) payload["requires_restart"] = True diff --git a/nanobot/skills/update-setup/SKILL.md b/nanobot/skills/update-setup/SKILL.md index 66cc8a51..ab19a977 100644 --- a/nanobot/skills/update-setup/SKILL.md +++ b/nanobot/skills/update-setup/SKILL.md @@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path: **Question 2 — Optional dependencies:** ``` -question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, feishu, langfuse, matrix, mochat, msteams, napcat, olostep, qq, slack, telegram, wecom, weixin" +question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, langfuse, olostep. Channel dependencies are installed from their manifests when the WebUI gateway starts." ``` Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names. diff --git a/nanobot/webui/build.py b/nanobot/webui/build.py index d68dce62..950cf1f0 100644 --- a/nanobot/webui/build.py +++ b/nanobot/webui/build.py @@ -90,6 +90,10 @@ def iter_webui_source_files(source_dir: Path) -> list[Path]: if not root.is_dir(): continue files.extend(path for path in root.rglob("*") if path.is_file()) + channel_root = source_dir.parent / "nanobot" / "channels" + if channel_root.is_dir(): + for channel_webui in channel_root.glob("*/webui"): + files.extend(path for path in channel_webui.rglob("*") if path.is_file()) return files diff --git a/nanobot/webui/channel_validation.py b/nanobot/webui/channel_validation.py deleted file mode 100644 index 0358d067..00000000 --- a/nanobot/webui/channel_validation.py +++ /dev/null @@ -1,541 +0,0 @@ -"""Best-effort Channel setup validation for the WebUI. - -Validation is intentionally non-authoritative: it helps the UI explain whether a -channel looks ready, but it never writes config and it does not replace runtime -channel startup semantics. -""" - -from __future__ import annotations - -import re -import socket -import ssl -from datetime import UTC, datetime -from typing import Any - -import httpx - -from nanobot.channels._setup import channel_setup_spec -from nanobot.config.loader import load_config -from nanobot.security.network import resolve_url_target - -CheckStatus = str -SetupStatus = str - -_TIMEOUT_SECONDS = 4.0 - - -def _official_action(name: str) -> str | None: - spec = channel_setup_spec(name) - return spec.official_url if spec is not None else None - - -def validate_channel_config( - name: str, - raw_values: dict[str, Any] | None = None, - *, - instance_id: str = "default", -) -> dict[str, Any]: - """Validate a channel setup without mutating persisted config.""" - - channel = (name or "").strip() - if not channel: - return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")]) - - config = load_config() - section = getattr(config.channels, channel, None) - values = _channel_config(channel, section, instance_id=instance_id) - values = _merge_form_values(channel, values, raw_values or {}) - - validator = _VALIDATORS.get(channel, _validate_generic) - if channel == "email": - payload = _validate_email( - channel, - values, - allow_loopback=config.tools.webui_allow_local_service_access, - ) - else: - payload = validator(channel, values) - payload["name"] = channel - return payload - - -def _validate_websocket(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks = [ - _check( - "managed", - "Managed by WebUI", - "pass", - "The browser workbench prepares the local WebSocket channel.", - action_url=_official_action(name), - ) - ] - return _payload(name, "connected" if _enabled(values) else "configured", checks, can_enable=True) - - -def _validate_telegram(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks, missing = _required_checks(name, values) - token = _str(values.get("token")) - if token: - if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token): - checks.append(_check("token_format", "Token format", "fail", "Telegram tokens look like 123456:ABC...")) - else: - checks.append(_check("token_format", "Token format", "pass", "Looks like a BotFather token.")) - try: - data = _http_get(f"https://api.telegram.org/bot{token}/getMe") - if data.get("ok") and isinstance(data.get("result"), dict): - bot = data["result"] - identity = { - "name": bot.get("username") or bot.get("first_name"), - "account": str(bot.get("id") or ""), - } - checks.append(_check("get_me", "Bot identity", "pass", "Telegram accepted the bot token.")) - return _payload(name, "connected", checks, identity=identity, missing_fields=missing) - checks.append(_check("get_me", "Bot identity", "fail", _message_from_response(data, "Telegram rejected the token."))) - except httpx.HTTPStatusError as exc: - checks.append( - _check( - "get_me", - "Bot identity", - "warn", - f"Telegram could not verify the token: HTTP {exc.response.status_code}.", - ) - ) - except Exception: - checks.append( - _check( - "get_me", - "Bot identity", - "warn", - "Could not reach Telegram now. Try again later.", - ) - ) - return _status_from_checks(name, checks, missing) - - -def _validate_discord(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks, missing = _required_checks(name, values) - token = _str(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(name, "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(name, checks, missing) - - -def _validate_slack(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks, missing = _required_checks(name, values) - app_token = _str(values.get("appToken")) - bot_token = _str(values.get("botToken")) - if app_token: - checks.append( - _check( - "app_token_prefix", - "Socket Mode app token", - "pass" if app_token.startswith("xapp-") else "fail", - "App-level Socket Mode tokens start with xapp-.", - action_url=_official_action(name), - ) - ) - if bot_token: - checks.append( - _check( - "bot_token_prefix", - "Bot token", - "pass" if bot_token.startswith("xoxb-") else "fail", - "Bot tokens start with xoxb- after installing the Slack app.", - action_url=_official_action(name), - ) - ) - if bot_token.startswith("xoxb-"): - try: - data = _http_post( - "https://slack.com/api/auth.test", - headers={"Authorization": f"Bearer {bot_token}"}, - ) - if data.get("ok"): - identity = { - "name": data.get("user"), - "workspace": data.get("team"), - "account": data.get("user_id"), - } - checks.append(_check("auth_test", "Workspace identity", "pass", "Slack accepted the bot token.")) - status = "connected" if app_token.startswith("xapp-") else "configured" - return _payload(name, status, checks, identity=identity, missing_fields=missing) - checks.append(_check("auth_test", "Workspace identity", "fail", _message_from_response(data, "Slack rejected the bot token."))) - except Exception as exc: - checks.append(_check("auth_test", "Workspace identity", "warn", f"Could not reach Slack now: {exc}")) - return _status_from_checks(name, checks, missing) - - -def _validate_email( - name: str, - values: dict[str, Any], - *, - allow_loopback: bool = False, -) -> dict[str, Any]: - checks, missing = _required_checks(name, 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 = _str(values.get(f"{prefix}Host")) - port = _int(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=allow_loopback) - 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": _str(values.get("fromAddress") or values.get("imapUsername") or values.get("smtpUsername"))} - return _status_from_checks(name, checks, missing, identity=identity) - - -def _validate_feishu(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks, missing = _required_checks(name, values) - display_name = _str(values.get("displayName") or values.get("name")) - avatar_url = _str(values.get("avatarUrl")) - if _str(values.get("appId")).startswith(("cli_", "oapi_")): - checks.append(_check("app_id", "App ID", "pass", "A Feishu/Lark App ID is saved.")) - elif _str(values.get("appId")): - 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": _str(values.get("appId")), - } - return _payload(name, status, checks, identity=identity, missing_fields=missing) - - -def _validate_matrix(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks, missing = _required_checks(name, values) - password = _str(values.get("password")) - access_token = _str(values.get("accessToken")) - device_id = _str(values.get("deviceId")) - - if password: - checks.append(_check("login", "Login credentials", "pass", "Password login is configured.")) - elif access_token and device_id: - checks.append( - _check( - "login", - "Login credentials", - "pass", - "Access token login is configured with its device ID.", - ) - ) - else: - if not password and not access_token: - missing.append("password_or_accessToken") - message = "Add a password, or an access token with its device ID." - else: - missing.append("deviceId") - message = "A device ID is required with an access token." - checks.append(_check("login", "Login credentials", "fail", message)) - - checks.append( - _check( - "manual_review", - "Matrix account", - "skipped", - "Room access is verified when the channel starts.", - ) - ) - return _status_from_checks(name, checks, list(dict.fromkeys(missing))) - - -def _validate_cli_handoff(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks: list[dict[str, Any]] = [] - if _enabled(values) or _str(values.get("token")) or _str(values.get("databasePath")): - checks.append(_check("local_state", "Local login state", "pass", "Saved local login state was detected.")) - return _payload(name, "configured", checks, can_enable=True) - checks.append( - _check( - "terminal_login", - "Terminal login", - "skipped", - "This channel uses a terminal QR login flow.", - action_url=_official_action(name), - ) - ) - return _payload(name, "needs_setup", checks, missing_fields=["terminal_login"], can_enable=False) - - -def _validate_generic(name: str, values: dict[str, Any]) -> dict[str, Any]: - checks, missing = _required_checks(name, values) - spec = channel_setup_spec(name) - if spec is not None and spec.required: - checks.append(_check("manual_review", "Manual setup", "skipped", "This channel can be checked from saved fields, but not fully verified in-browser.")) - return _status_from_checks(name, checks, missing) - if _enabled(values): - return _payload(name, "configured", [_check("enabled", "Enabled", "pass", "This channel is enabled.")]) - return _payload(name, "unsupported", [_check("support", "WebUI setup", "skipped", "This channel is not configurable from the WebUI yet.")]) - - -_VALIDATORS = { - "websocket": _validate_websocket, - "telegram": _validate_telegram, - "discord": _validate_discord, - "slack": _validate_slack, - "email": _validate_email, - "feishu": _validate_feishu, - "matrix": _validate_matrix, - "whatsapp": _validate_cli_handoff, - "weixin": _validate_cli_handoff, -} - - -def _channel_config(name: str, section: Any, *, instance_id: str) -> dict[str, Any]: - if name == "feishu": - try: - from nanobot.channels._feishu_instances import feishu_instance_specs - from nanobot.channels.feishu import FeishuChannel - - specs = feishu_instance_specs(section, FeishuChannel.default_config()) - selected = next((spec for spec in specs if spec.instance_id == instance_id), None) - return dict(selected.config) if selected is not None else {} - except Exception: - return {} - if hasattr(section, "model_dump"): - return dict(section.model_dump(mode="json", by_alias=True)) - if isinstance(section, dict): - return dict(section) - return {} - - -def _merge_form_values( - name: str, - values: dict[str, Any], - raw_values: dict[str, Any], -) -> dict[str, Any]: - merged = dict(values) - prefix = f"channels.{name}." - spec = channel_setup_spec(name) - secrets = spec.secrets if spec is not None else frozenset() - for raw_key, raw_value in raw_values.items(): - if not isinstance(raw_key, str) or not raw_key: - continue - field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key - if field in secrets and not _str(raw_value): - continue - _assign(merged, field, raw_value) - return merged - - -def _required_checks(name: str, values: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: - checks: list[dict[str, Any]] = [] - missing: list[str] = [] - spec = channel_setup_spec(name) - for field in spec.simple_required_fields if spec is not None else (): - value = _get(values, field) - if field == "consentGranted": - if not _truthy(value): - missing.append(field) - continue - if _str(value): - checks.append(_check(f"field:{field}", _label(field), "pass", "Configured.")) - else: - missing.append(field) - checks.append(_check(f"field:{field}", _label(field), "fail", "Required.")) - return checks, missing - - -def _status_from_checks( - name: str, - checks: list[dict[str, Any]], - missing: list[str], - *, - identity: dict[str, Any] | None = None, -) -> dict[str, Any]: - if missing: - return _payload(name, "needs_setup", checks, identity=identity, missing_fields=missing, can_enable=False) - if any(check["status"] == "fail" for check in checks): - return _payload(name, "invalid", checks, identity=identity, missing_fields=missing, can_enable=False) - if any(check["status"] == "warn" for check in checks) or any(check["status"] == "skipped" for check in checks): - return _payload(name, "configured", checks, identity=identity, missing_fields=missing) - return _payload(name, "connected", checks, identity=identity, missing_fields=missing) - - -def _payload( - name: str, - status: SetupStatus, - checks: list[dict[str, Any]], - *, - identity: dict[str, Any] | None = None, - missing_fields: list[str] | None = None, - can_enable: bool | None = None, -) -> dict[str, Any]: - missing = missing_fields or [] - return { - "name": name, - "status": status, - "checks": checks, - "identity": {key: value for key, value in (identity or {}).items() if value}, - "missing_fields": missing, - "can_enable": status not in {"needs_setup", "invalid", "unsupported"} and not missing - if can_enable is None - else can_enable, - "requires_restart": False, - "checked_at": datetime.now(UTC).isoformat(), - "message": _status_message(status), - } - - -def _check( - check_id: str, - label: str, - status: CheckStatus, - message: str | None = None, - *, - action_url: str | None = None, -) -> dict[str, Any]: - payload: dict[str, Any] = {"id": check_id, "label": label, "status": status} - if message: - payload["message"] = message - if action_url: - payload["action_url"] = action_url - return payload - - -def _assign(values: dict[str, Any], field: str, value: Any) -> None: - target = values - parts = field.split(".") - for part in parts[:-1]: - current = target.get(part) - if not isinstance(current, dict): - current = {} - target[part] = current - target = current - target[parts[-1]] = value - - -def _get(values: dict[str, Any], field: str) -> Any: - target: Any = values - for part in field.split("."): - if not isinstance(target, dict): - return None - target = target.get(part) - return target - - -def _str(value: Any) -> str: - if value is None: - return "" - if isinstance(value, str): - return value.strip() - return str(value).strip() - - -def _int(value: Any) -> int | None: - if value in (None, ""): - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _truthy(value: Any) -> bool: - if isinstance(value, bool): - return value - return _str(value).lower() in {"1", "true", "yes", "on", "granted"} - - -def _enabled(values: dict[str, Any]) -> bool: - return _truthy(values.get("enabled")) - - -def _label(field: str) -> str: - words = re.sub(r"([a-z])([A-Z])", r"\1 \2", field).replace(".", " ").replace("_", " ") - return words[:1].upper() + words[1:] - - -def _status_message(status: str) -> str: - return { - "connected": "Connection verified.", - "configured": "Configuration is present, but full verification was not possible.", - "needs_setup": "Required setup is missing.", - "invalid": "Configuration was checked and looks invalid.", - "unsupported": "This channel is not supported by the WebUI setup checker.", - }.get(status, "Channel checked.") - - -def _message_from_response(data: dict[str, Any], fallback: str) -> str: - error = data.get("error") or data.get("description") or data.get("message") - return str(error) if error else fallback - - -def _http_get(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]: - with httpx.Client(timeout=_TIMEOUT_SECONDS) as client: - response = client.get(url, headers=headers) - response.raise_for_status() - data = response.json() - return data if isinstance(data, dict) else {} - - -def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]: - with httpx.Client(timeout=_TIMEOUT_SECONDS) as client: - response = client.post(url, headers=headers) - response.raise_for_status() - data = response.json() - return data if isinstance(data, dict) else {} - - -def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None: - url_host = host if ":" not in host or host.startswith("[") else f"[{host}]" - ok, error, resolved_ips = resolve_url_target( - f"http://{url_host}:{port}/", - allow_loopback=allow_loopback, - ) - if not ok: - raise ValueError(error) - - context = ssl.create_default_context() - last_error: OSError | None = None - for target_ip in resolved_ips: - try: - with socket.create_connection((target_ip, port), timeout=_TIMEOUT_SECONDS) as sock: - if port in {465, 993, 995}: - with context.wrap_socket(sock, server_hostname=host.strip("[]")): - return - return - except OSError as exc: - last_error = exc - if last_error is not None: - raise last_error - raise OSError(f"Could not resolve {host}") diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index 4f015ee4..6bf438f3 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -50,6 +50,7 @@ def build_gateway_services( cron_pending_job_ids: Callable[[str], set[str]] | None = None, local_trigger_pending_ids: Callable[[str], set[str]] | None = None, channel_feature_action: Callable[..., Any] | None = None, + channel_runtime_status: Callable[[], dict[str, Any]] | None = None, logger: Any = default_logger, ) -> GatewayServices: tokens = GatewayTokenStore() @@ -92,6 +93,7 @@ def build_gateway_services( cron_pending_job_ids=cron_pending_job_ids, local_trigger_pending_ids=local_trigger_pending_ids, channel_feature_action=channel_feature_action, + channel_runtime_status=channel_runtime_status, log=logger, ) return GatewayServices( diff --git a/nanobot/webui/nanobot_features_api.py b/nanobot/webui/nanobot_features_api.py index e8c7a9e5..5e70a6a6 100644 --- a/nanobot/webui/nanobot_features_api.py +++ b/nanobot/webui/nanobot_features_api.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID +from nanobot.channels.registry import load_channel_plugin from nanobot.optional_features import ( OptionalFeatureError, disable_optional_feature, @@ -19,6 +19,14 @@ def nanobot_features_payload() -> dict[str, Any]: return optional_features_payload() +def nanobot_feature_instance_target(query: QueryParams) -> str | None: + """Preserve the difference between a global action and an explicit instance.""" + instance_id = query_first(query, "instance_id") + if instance_id is None: + return None + return instance_id.strip() or None + + def nanobot_features_action( action: str, query: QueryParams, @@ -26,16 +34,20 @@ def nanobot_features_action( allow_install: bool = True, ) -> dict[str, Any]: name = (query_first(query, "name") or "").strip() - instance_id = (query_first(query, "instance_id") or DEFAULT_INSTANCE_ID).strip() + instance_id = nanobot_feature_instance_target(query) if not name: raise OptionalFeatureError("missing feature name") if action == "enable": return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id) if action == "disable": - if name == "websocket": + try: + plugin = load_channel_plugin(name) + except ImportError: + plugin = None + if plugin is not None and "always_enabled" in plugin.capabilities: raise OptionalFeatureError( - "The WebUI websocket channel cannot be disabled from WebUI. " - "Use `nanobot plugins disable websocket` from a terminal if you need to disable it.", + f"The {plugin.display_name} channel cannot be disabled from WebUI. " + f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.", status=400, ) return disable_optional_feature(name, instance_id=instance_id) diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index de76a75d..1464b8bb 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -21,24 +21,30 @@ from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths from nanobot.bus.queue import MessageBus from nanobot.channels._setup import channel_setup_spec +from nanobot.channels.connect import ChannelConnectError +from nanobot.channels.contracts import ( + channel_instance_config, + channel_update_instance_config, +) +from nanobot.channels.registry import load_channel_plugin +from nanobot.channels.validation import validate_channel_config from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.optional_features import ( OptionalFeatureError, extra_installed, optional_dependency_groups, + with_channel_runtime_status, ) from nanobot.pairing import approve_code, deny_code, list_pending -from nanobot.webui.channel_connect import ( - ChannelConnectError, - FeishuConnectStore, - WeixinConnectStore, -) -from nanobot.webui.channel_validation import validate_channel_config from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request from nanobot.webui.http_utils import query_first as _query_first from nanobot.webui.mcp_presets_api import mcp_presets_settings_action -from nanobot.webui.nanobot_features_api import nanobot_features_action, nanobot_features_payload +from nanobot.webui.nanobot_features_api import ( + nanobot_feature_instance_target, + nanobot_features_action, + nanobot_features_payload, +) from nanobot.webui.settings_api import ( WebUISettingsError, create_model_configuration, @@ -69,6 +75,18 @@ _API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values" _API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024 _SKIP_FIELD = object() +_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"}) + + +def _channel_connect_route(path: str) -> tuple[str, str] | None: + prefix = "/api/settings/channels/" + if not path.startswith(prefix): + return None + parts = path.removeprefix(prefix).split("/") + if len(parts) != 3 or parts[1] != "connect" or parts[2] not in _CHANNEL_CONNECT_ACTIONS: + return None + channel_name = parts[0].strip() + return (channel_name, parts[2]) if channel_name else None _MCP_PRESET_ACTIONS_BY_PATH = { "/api/settings/mcp-presets/enable": "enable", @@ -96,6 +114,7 @@ class WebUISettingsRouter: runtime_surface: str, runtime_capabilities: dict[str, Any], channel_feature_action: Callable[..., Any] | None = None, + channel_runtime_status: Callable[[], dict[str, Any]] | None = None, ) -> None: self.bus = bus self.logger = logger @@ -106,9 +125,9 @@ class WebUISettingsRouter: self._runtime_surface = runtime_surface self._runtime_capabilities = runtime_capabilities self._channel_feature_action = channel_feature_action + self._channel_runtime_status = channel_runtime_status self._restart_sections: set[str] = set() - self._feishu_connect = FeishuConnectStore() - self._weixin_connect = WeixinConnectStore() + self._channel_connectors: dict[str, Any] = {} async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None: if path == "/api/settings": @@ -159,18 +178,15 @@ class WebUISettingsRouter: return await self._handle_settings_nanobot_features_action(connection, request, "enable") if path == "/api/settings/nanobot-features/disable": return await self._handle_settings_nanobot_features_action(connection, request, "disable") - if path == "/api/settings/channels/feishu/connect/start": - return await self._handle_settings_feishu_connect_start(request) - if path == "/api/settings/channels/feishu/connect/poll": - return await self._handle_settings_feishu_connect_poll(connection, request) - if path == "/api/settings/channels/feishu/connect/cancel": - return self._handle_settings_feishu_connect_cancel(request) - if path == "/api/settings/channels/weixin/connect/start": - return await self._handle_settings_weixin_connect_start(connection, request) - if path == "/api/settings/channels/weixin/connect/poll": - return await self._handle_settings_weixin_connect_poll(connection, request) - if path == "/api/settings/channels/weixin/connect/cancel": - return await self._handle_settings_weixin_connect_cancel(request) + channel_connect = _channel_connect_route(path) + if channel_connect is not None: + channel_name, action = channel_connect + return await self._handle_settings_channel_connect( + connection, + request, + channel_name, + action, + ) if path == "/api/settings/channels/validate": return await self._handle_settings_channel_validate(request) if path == "/api/settings/channels/configure": @@ -574,7 +590,7 @@ class WebUISettingsRouter: except Exception: self.logger.exception("failed to load nanobot features") return self._error_response(500, "failed to load nanobot features") - return self._json_response(payload) + return self._json_response(self._with_channel_runtime_status(payload)) async def _handle_settings_nanobot_features_action( self, @@ -605,8 +621,18 @@ class WebUISettingsRouter: self._query(request), payload, ) + payload = self._with_channel_runtime_status(payload) return self._json_response(self._with_restart_state(payload, section="runtime")) + def _with_channel_runtime_status(self, payload: dict[str, Any]) -> dict[str, Any]: + if self._channel_runtime_status is None: + return payload + try: + return with_channel_runtime_status(payload, self._channel_runtime_status()) + except Exception: + self.logger.exception("failed to load channel runtime status") + return payload + async def _apply_nanobot_feature_runtime_change( self, action: str, @@ -621,11 +647,8 @@ class WebUISettingsRouter: return payload try: - instance_id = (_query_first(query, "instance_id") or "").strip() - runtime_name = name - if name == "feishu" and instance_id and instance_id != "default": - runtime_name = f"feishu.{instance_id}" - result = self._channel_feature_action(action, runtime_name) + instance_id = nanobot_feature_instance_target(query) + result = self._channel_feature_action(action, name, instance_id) if inspect.isawaitable(result): result = await result except Exception as exc: @@ -653,6 +676,8 @@ class WebUISettingsRouter: else: last_action["message"] = message last_action["hot_reload"] = not payload["requires_restart"] + if "ok" in result: + last_action["ok"] = bool(result["ok"]) payload["last_action"] = last_action return payload @@ -697,10 +722,13 @@ class WebUISettingsRouter: "saved_keys": saved, } if not enable: + features = await asyncio.to_thread(nanobot_features_payload) + features = self._with_channel_runtime_status(features) + payload["nanobot_features"] = self._with_restart_state(features, section="runtime") return self._json_response(payload) feature_query = {"name": [name]} - if name == "feishu": + if instance_id: feature_query["instance_id"] = [instance_id] try: @@ -721,6 +749,7 @@ class WebUISettingsRouter: feature_query, features, ) + features = self._with_channel_runtime_status(features) payload["nanobot_features"] = self._with_restart_state(features, section="runtime") return self._json_response(payload) @@ -766,7 +795,11 @@ class WebUISettingsRouter: ) -> list[str]: if not name: raise WebUISettingsError("missing channel name") - setup_spec = channel_setup_spec(name) + try: + plugin = load_channel_plugin(name) + except ImportError: + raise WebUISettingsError(f"unknown channel '{name}'", status=404) from None + setup_spec = channel_setup_spec(name, plugin=plugin) if setup_spec is None: raise WebUISettingsError(f"channel '{name}' cannot be configured from WebUI", status=404) field_types = setup_spec.route_field_types @@ -775,19 +808,11 @@ class WebUISettingsRouter: config = load_config() section = getattr(config.channels, name, None) - if name == "feishu": - from nanobot.channels._feishu_instances import feishu_instance_specs - from nanobot.channels.feishu import FeishuChannel - - specs = feishu_instance_specs(section, FeishuChannel.default_config()) - selected = next((spec for spec in specs if spec.instance_id == instance_id), None) - channel_config = dict(selected.config) if selected is not None else {} - elif hasattr(section, "model_dump"): - channel_config = section.model_dump(mode="json", by_alias=True) - elif isinstance(section, dict): - channel_config = dict(section) - else: - channel_config = {} + channel_config = channel_instance_config( + plugin, + section, + instance_id=instance_id, + ) saved: list[str] = [] prefix = f"channels.{name}." @@ -804,19 +829,19 @@ class WebUISettingsRouter: self._assign_channel_config_value(channel_config, field, value) saved.append(raw_key) - if name == "feishu": - from nanobot.channels._feishu_instances import upsert_feishu_instance - from nanobot.channels.feishu import FeishuChannel - - existing = getattr(config.channels, name, None) - channel_config = upsert_feishu_instance( - existing if isinstance(existing, dict) else {}, - FeishuChannel.default_config(), - instance_id, + try: + updated_section = channel_update_instance_config( + plugin, + section, channel_config, + instance_id=instance_id, ) - - setattr(config.channels, name, channel_config) + except ValueError as exc: + raise WebUISettingsError( + f"Invalid {name} configuration: {exc}", + status=400, + ) from exc + setattr(config.channels, name, updated_section) save_config(config) return saved @@ -885,143 +910,46 @@ class WebUISettingsRouter: target = current target[parts[-1]] = value - async def _handle_settings_feishu_connect_start(self, request: WsRequest) -> Response: + async def _handle_settings_channel_connect( + self, + connection: Any, + request: WsRequest, + channel_name: str, + action: str, + ) -> Response: if not self._authorized(request): return self._unauthorized() - query = self._query(request) - 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() + try: - payload = await asyncio.to_thread( - self._feishu_connect.start, - domain=domain, - instance_id=instance_id, - mode=mode, + connector = self._channel_connectors.get(channel_name) + if connector is None: + plugin = load_channel_plugin(channel_name) + connector = plugin.load_connector() + self._channel_connectors[channel_name] = connector + except ImportError: + return self._error_response(404, f"channel '{channel_name}' does not support connect") + + try: + payload = await connector.handle(action, self._query(request)) + except ChannelConnectError as exc: + return self._error_response(exc.status, exc.message) + except Exception: + self.logger.exception( + "failed to run {} WebUI connect action for {}", + action, + channel_name, ) - except ChannelConnectError as e: - return self._error_response(e.status, e.message) - except Exception: - self.logger.exception("failed to start Feishu WebUI connect") - return self._error_response(500, "failed to start Feishu connection") - return self._json_response(payload) - - async def _handle_settings_feishu_connect_poll( - self, - connection: Any, - request: WsRequest, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - session_id = (_query_first(self._query(request), "session_id") or "").strip() - if not session_id: - return self._error_response(400, "missing Feishu connect session") - - try: - payload = await asyncio.to_thread(self._feishu_connect.poll, session_id) - except Exception: - self.logger.exception("failed to poll Feishu WebUI connect") - return self._error_response(500, "failed to poll Feishu connection") - - if payload.get("status") == "succeeded": - try: - features = await asyncio.to_thread( - nanobot_features_action, - "enable", - { - "name": ["feishu"], - "instance_id": [str(payload.get("instance_id") or "default")], - }, - allow_install=self._allow_feature_package_install(connection, request), - ) - except OptionalFeatureError as exc: - features = self._feature_runtime_fallback( - nanobot_features_payload(), - message=f"Feishu connected, but enabling channel support failed: {exc.message}", - ) - else: - features = await self._apply_nanobot_feature_runtime_change( - "enable", - { - "name": ["feishu"], - "instance_id": [str(payload.get("instance_id") or "default")], - }, - features, - ) - payload = dict(payload) - payload["nanobot_features"] = self._with_restart_state(features, section="runtime") - - return self._json_response(payload) - - def _handle_settings_feishu_connect_cancel(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - session_id = (_query_first(self._query(request), "session_id") or "").strip() - if not session_id: - return self._error_response(400, "missing Feishu connect session") - return self._json_response(self._feishu_connect.cancel(session_id)) - - async def _handle_settings_weixin_connect_start( - self, - connection: Any, - request: WsRequest, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - force = (_query_first(self._query(request), "force") or "").strip().lower() in { - "1", - "true", - "yes", - } - try: - payload = await self._weixin_connect.start(force=force) - except ChannelConnectError as e: - return self._error_response(e.status, e.message) - except Exception: - self.logger.exception("failed to start WeChat WebUI connect") - return self._error_response(500, "failed to start WeChat connection") + return self._error_response(500, f"failed to {action} {channel_name} connection") if payload.get("status") == "succeeded": payload = await self._with_channel_connect_success( connection, request, - "weixin", + channel_name, payload, ) return self._json_response(payload) - async def _handle_settings_weixin_connect_poll( - self, - connection: Any, - request: WsRequest, - ) -> Response: - if not self._authorized(request): - return self._unauthorized() - session_id = (_query_first(self._query(request), "session_id") or "").strip() - if not session_id: - return self._error_response(400, "missing WeChat connect session") - try: - payload = await self._weixin_connect.poll(session_id) - except Exception: - self.logger.exception("failed to poll WeChat WebUI connect") - return self._error_response(500, "failed to poll WeChat connection") - if payload.get("status") == "succeeded": - payload = await self._with_channel_connect_success( - connection, - request, - "weixin", - payload, - ) - return self._json_response(payload) - - async def _handle_settings_weixin_connect_cancel(self, request: WsRequest) -> Response: - if not self._authorized(request): - return self._unauthorized() - session_id = (_query_first(self._query(request), "session_id") or "").strip() - if not session_id: - return self._error_response(400, "missing WeChat connect session") - return self._json_response(await self._weixin_connect.cancel(session_id)) - async def _with_channel_connect_success( self, connection: Any, @@ -1029,11 +957,14 @@ class WebUISettingsRouter: channel_name: str, payload: dict[str, Any], ) -> dict[str, Any]: + target = {"name": [channel_name]} + if payload.get("instance_id"): + target["instance_id"] = [str(payload["instance_id"])] try: features = await asyncio.to_thread( nanobot_features_action, "enable", - {"name": [channel_name]}, + target, allow_install=self._allow_feature_package_install(connection, request), ) except OptionalFeatureError as exc: @@ -1047,9 +978,10 @@ class WebUISettingsRouter: else: features = await self._apply_nanobot_feature_runtime_change( "enable", - {"name": [channel_name]}, + target, features, ) + features = self._with_channel_runtime_status(features) payload = dict(payload) payload["nanobot_features"] = self._with_restart_state(features, section="runtime") return payload diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index ed2f0dc8..eac92ed5 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -169,6 +169,7 @@ class GatewayHTTPHandler: cron_pending_job_ids: Callable[[str], set[str]] | None = None, local_trigger_pending_ids: Callable[[str], set[str]] | None = None, channel_feature_action: Callable[..., Any] | None = None, + channel_runtime_status: Callable[[], dict[str, Any]] | None = None, log: Any = logger, ) -> None: self.config = config @@ -203,6 +204,7 @@ class GatewayHTTPHandler: runtime_surface=runtime_surface, runtime_capabilities=self._capabilities, channel_feature_action=channel_feature_action, + channel_runtime_status=channel_runtime_status, ) def workspace_controls_available(self, connection: Any) -> bool: diff --git a/pyproject.toml b/pyproject.toml index 15863eb5..0ee6c8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,9 +67,6 @@ azure = [ bedrock = [ "boto3>=1.43.0", ] -dingtalk = [ - "dingtalk-stream>=0.24.0,<1.0.0", -] documents = [ # Compatibility extra: document readers are bundled since v0.2.3. "defusedxml>=0.7.1,<1.0.0", @@ -78,56 +75,6 @@ documents = [ "openpyxl>=3.1.0,<4.0.0", "python-pptx>=1.0.0,<2.0.0", ] -feishu = [ - "lark-oapi>=1.5.0,<2.0.0", -] -mochat = [ - "python-socketio>=5.16.0,<6.0.0", - "msgpack>=1.1.0,<2.0.0", -] -napcat = [ - "aiohttp>=3.9.0,<4.0.0", -] -qq = [ - "aiohttp>=3.9.0,<4.0.0", - "qq-botpy>=1.2.0,<2.0.0", -] -slack = [ - "aiohttp>=3.9.0,<4.0.0", - "slack-sdk>=3.39.0,<4.0.0", - "slackify-markdown>=0.2.0,<1.0.0", -] -telegram = [ - "python-telegram-bot[socks,webhooks]>=22.6,<23.0", - "socksio>=1.0.0,<2.0.0", - "python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'", -] -wecom = [ - "wecom-aibot-sdk-python>=0.1.5", -] -weixin = [ - "qrcode[pil]>=8.0", - "pycryptodome>=3.20.0", -] -msteams = [ - "PyJWT>=2.0,<3.0", - "cryptography>=41.0", -] - -matrix = [ - "matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'", - "matrix-nio>=0.25.2; sys_platform == 'win32'", - "aiohttp>=3.9.0,<4.0.0", - "mistune>=3.0.0,<4.0.0", - "nh3>=0.2.17,<1.0.0", -] -discord = [ - "discord.py>=2.5.2,<3.0.0", -] -whatsapp = [ - "neonize>=0.3.18.post0,<0.4.0", - "segno>=1.6.1,<2.0.0", -] langfuse = [ "langfuse>=3.0.0,<4.0.0", ] @@ -177,6 +124,7 @@ include = [ "nanobot/templates/**/*.md", "nanobot/skills/**/*.md", "nanobot/skills/**/*.sh", + "nanobot/channels/*/webui/**/*", "nanobot/web/dist/**/*", ] # nanobot/web/dist/ is produced by `cd webui && bun run build` and is @@ -188,6 +136,7 @@ artifacts = [ [tool.hatch.build.targets.wheel] packages = ["nanobot"] +exclude = ["nanobot/channels/*/tests/**"] [tool.hatch.build.targets.wheel.sources] "nanobot" = "nanobot" @@ -202,6 +151,7 @@ include = [ "THIRD_PARTY_NOTICES.md", "pyproject.toml", ] +exclude = ["nanobot/channels/*/tests/**"] [tool.ruff] line-length = 100 @@ -213,7 +163,7 @@ ignore = ["E501"] [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["tests"] +testpaths = ["tests", "nanobot/channels"] [tool.coverage.run] source = ["nanobot"] diff --git a/tests/channels/test_channel_contracts.py b/tests/channels/test_channel_contracts.py new file mode 100644 index 00000000..884cb818 --- /dev/null +++ b/tests/channels/test_channel_contracts.py @@ -0,0 +1,588 @@ +"""Shared contract tests for self-contained channel packages.""" + +from __future__ import annotations + +import subprocess +import sys +from typing import Any + +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.channels._setup import channel_setup_spec +from nanobot.channels.base import BaseChannel +from nanobot.channels.contracts import ( + ChannelActivation, + ChannelFieldSpec, + ChannelInstanceSpec, + ChannelManagementSpec, + ChannelSetupSpec, + ChannelValidationContext, + SetupRequirement, + channel_feature_instances, + channel_instance_config, + channel_instance_specs, + channel_runtime_name, + channel_set_config_enabled, + channel_update_instance_config, + resolve_channel_action_target, +) +from nanobot.channels.plugin import ChannelPlugin +from nanobot.channels.registry import discover_plugins, load_channel_plugin + + +class _SingleChannel(BaseChannel): + name = "single" + display_name = "Single" + + @classmethod + def default_config(cls) -> dict[str, Any]: + return {"enabled": False, "token": ""} + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, msg: OutboundMessage) -> None: + pass + + +class _SetupChannel(_SingleChannel): + name = "setup_contract" + + @staticmethod + def _validate( + values: dict[str, Any], + _context: ChannelValidationContext, + ) -> dict[str, Any]: + return { + "status": "connected" if values.get("token") else "invalid", + "checks": [], + } + + + +_SETUP_PLUGIN = ChannelPlugin( + name=_SetupChannel.name, + display_name=_SetupChannel.display_name, + runtime=f"{__name__}:_SetupChannel", + setup=ChannelSetupSpec( + fields={"token": ChannelFieldSpec(kind="secret")}, + required=(SetupRequirement((("token",),)),), + validator=_SetupChannel._validate, + ), +) + +_SINGLE_PLUGIN = ChannelPlugin( + name=_SingleChannel.name, + display_name=_SingleChannel.display_name, + runtime=f"{__name__}:_SingleChannel", + management=ChannelManagementSpec(default_config=_SingleChannel.default_config), +) + + +def test_management_contract_is_not_declared_on_runtime_base_class() -> None: + management_hooks = { + "feature_instances", + "instance_specs", + "runtime_name", + "supports_multiple_instances", + "update_instance_config", + } + + assert management_hooks.isdisjoint(BaseChannel.__dict__.keys()) + assert "refresh_feature_metadata" in BaseChannel.__dict__ + + +def test_multi_instance_support_is_declared_by_management_spec() -> None: + assert _SINGLE_PLUGIN.management.multi_instance is False + assert load_channel_plugin("feishu").management.multi_instance is True + + +@pytest.mark.parametrize( + "callback", + [ + "instance_specs", + "update_instance_config", + "runtime_name", + "feature_instances", + ], +) +def test_single_instance_management_rejects_multi_instance_callbacks(callback: str) -> None: + with pytest.raises(ValueError, match=callback): + ChannelManagementSpec(**{callback: lambda *args, **kwargs: None}) + + +@pytest.mark.parametrize( + ("requested", "expected"), + [ + pytest.param(None, "default", id="default-instance"), + pytest.param("product", "product", id="explicit-instance"), + ], +) +def test_channel_action_target_contract( + requested, + expected, +) -> None: + assert resolve_channel_action_target(requested) == expected + + +def test_contract_module_is_not_discovered_as_a_channel() -> None: + assert "contracts" not in discover_plugins() + assert "manifests" not in discover_plugins() + + +def test_settings_contract_import_does_not_eagerly_load_runtime_graph() -> None: + code = """ +import sys +import nanobot.channels.validation + +unexpected = { + "nanobot.channels.manager", + "nanobot.channels.websocket", + "nanobot.webui.gateway_services", +} & sys.modules.keys() +assert not unexpected, sorted(unexpected) +""" + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + ("section", "default", "include_instances", "expected"), + [ + pytest.param({"enabled": True}, False, False, True, id="flat-enabled"), + pytest.param({}, True, False, True, id="flat-inherits-default"), + pytest.param( + {"enabled": True, "instances": ["plugin-owned-value"]}, + False, + False, + True, + id="single-instance-plugin-owns-instances-field", + ), + pytest.param( + {"enabled": False, "instances": [{"enabled": True}]}, + False, + True, + True, + id="instance-overrides-parent", + ), + pytest.param( + {"enabled": True, "instances": [{}, {"enabled": False}]}, + False, + True, + True, + id="instance-inherits-parent", + ), + pytest.param( + {"enabled": True, "instances": []}, + False, + True, + False, + id="empty-instance-list", + ), + ], +) +def test_channel_activation_normalizes_persisted_config( + section: dict[str, Any], + default: bool, + include_instances: bool, + expected: bool, +) -> None: + activation = ChannelActivation.from_config( + section, + include_instances=include_instances, + ) + + assert activation.resolve(default=default) is expected + + +def _instance_contract_cases(): + return [ + pytest.param( + _SINGLE_PLUGIN, + {"enabled": True, "token": "saved"}, + "default", + {"default"}, + id="single-instance-default", + ), + pytest.param( + load_channel_plugin("feishu"), + { + "instances": [ + { + "id": "default", + "enabled": True, + "appId": "cli_default", + "appSecret": "secret", + }, + { + "id": "product", + "enabled": True, + "appId": "cli_product", + "appSecret": "secret", + }, + ] + }, + "product", + {"default", "product"}, + id="feishu-multi-instance", + ), + ] + + +@pytest.mark.parametrize( + ("plugin", "section", "target_id", "expected_ids"), + _instance_contract_cases(), +) +def test_channel_instance_contract_round_trip( + plugin, + section, + target_id, + expected_ids, +) -> None: + all_specs = channel_instance_specs(plugin, section, enabled_only=False) + enabled_specs = channel_instance_specs(plugin, section) + + assert {spec.instance_id for spec in all_specs} == expected_ids + assert {spec.instance_id for spec in enabled_specs} == expected_ids + runtime_names = {channel_runtime_name(plugin, spec.instance_id) for spec in all_specs} + assert len(runtime_names) == len(all_specs) + + disabled = channel_set_config_enabled( + plugin, + section, + False, + instance_id=target_id, + ) + assert target_id not in { + spec.instance_id for spec in channel_instance_specs(plugin, disabled) + } + + values = channel_instance_config(plugin, disabled, instance_id=target_id) + values["contractMarker"] = "preserved" + updated = channel_update_instance_config( + plugin, + disabled, + values, + instance_id=target_id, + ) + assert channel_instance_config( + plugin, + updated, + instance_id=target_id, + )["contractMarker"] == "preserved" + + +def test_channel_feature_instances_use_generic_setup_snapshot() -> None: + setup_spec = ChannelSetupSpec( + fields={ + "token": ChannelFieldSpec(kind="secret"), + "region": ChannelFieldSpec(kind="enum", choices=frozenset({"eu", "us"})), + "topicIsolation": ChannelFieldSpec(kind="bool"), + }, + required=(SetupRequirement.field("token"),), + ) + plugin = ChannelPlugin( + name="feature_multi", + display_name="Feature multi", + runtime=f"{__name__}:_SingleChannel", + setup=setup_spec, + management=ChannelManagementSpec( + multi_instance=True, + instance_specs=lambda section, *, enabled_only=True: [ + ChannelInstanceSpec(item["id"], item) + for item in section["instances"] + if not enabled_only or item["enabled"] + ], + update_instance_config=lambda section, values, *, instance_id="default": section, + runtime_name=lambda name, instance_id: ( + name if instance_id == "default" else f"{name}.{instance_id}" + ), + feature_instances=lambda section, *, setup_spec=None: [{ + "id": "product", + "display_name": "Catalog product helper", + "enabled": False, + "config_values": {"channels.feature_multi.token": "leaked"}, + }], + ), + ) + section = { + "instances": [ + { + "id": "product", + "name": "Product bot", + "displayName": "Product helper", + "avatarUrl": "https://example.com/product.png", + "enabled": True, + "token": "secret", + "region": "eu", + "topicIsolation": False, + } + ] + } + + instances = channel_feature_instances( + plugin, + section, + setup_spec=setup_spec, + ) + + assert instances == [ + { + "id": "product", + "name": "Product bot", + "display_name": "Catalog product helper", + "avatar_url": "https://example.com/product.png", + "enabled": True, + "configured": True, + "config_values": { + "channels.feature_multi.region": "eu", + "channels.feature_multi.topicIsolation": "false", + }, + "configured_fields": [ + "channels.feature_multi.token", + "channels.feature_multi.region", + "channels.feature_multi.topicIsolation", + ], + } + ] + + +def test_feishu_instance_contract_skips_duplicate_app_identity() -> None: + section = { + "instances": [ + { + "id": "default", + "enabled": True, + "appId": "cli_same", + "appSecret": "secret", + "domain": "feishu", + }, + { + "id": "assistant-copy", + "enabled": True, + "appId": "cli_same", + "appSecret": "secret", + "domain": "feishu", + }, + ] + } + + specs = channel_instance_specs(load_channel_plugin("feishu"), section) + + assert [spec.instance_id for spec in specs] == ["default"] + + +def test_feishu_feature_state_matches_runtime_duplicate_filter() -> None: + section = { + "instances": [ + { + "id": "default", + "enabled": True, + "appId": "cli_same", + "appSecret": "secret", + "domain": "feishu", + }, + { + "id": "assistant-copy", + "enabled": True, + "appId": "cli_same", + "appSecret": "secret", + "domain": "feishu", + }, + ] + } + + instances = channel_feature_instances( + load_channel_plugin("feishu"), + section, + setup_spec=channel_setup_spec("feishu"), + ) + + assert instances is not None + assert [(item["id"], item["enabled"]) for item in instances] == [ + ("default", True), + ("assistant-copy", False), + ] + + +def test_feishu_runtime_duplicate_ignores_disabled_identity_owner() -> None: + section = { + "instances": [ + { + "id": "default", + "enabled": False, + "appId": "cli_same", + "appSecret": "secret-a", + "domain": "feishu", + }, + { + "id": "assistant-copy", + "enabled": True, + "appId": "cli_same", + "appSecret": "secret-b", + "domain": "feishu", + }, + ] + } + + specs = channel_instance_specs(load_channel_plugin("feishu"), section) + + assert [spec.instance_id for spec in specs] == ["assistant-copy"] + + +def test_feishu_instance_write_preserves_duplicate_app_identity() -> None: + section = { + "instances": [ + { + "id": "default", + "enabled": True, + "appId": "cli_same", + "appSecret": "secret-a", + }, + { + "id": "assistant-copy", + "enabled": True, + "appId": "cli_same", + "appSecret": "secret-b", + }, + ] + } + + updated = channel_set_config_enabled( + load_channel_plugin("feishu"), + section, + False, + instance_id="assistant-copy", + ) + + assert [instance["id"] for instance in updated["instances"]] == [ + "default", + "assistant-copy", + ] + assert updated["instances"][0]["appSecret"] == "secret-a" + assert updated["instances"][1]["appId"] == "cli_same" + assert updated["instances"][1]["appSecret"] == "secret-b" + assert updated["instances"][1]["enabled"] is False + + +def test_channel_instance_contract_materializes_generators() -> None: + def generate_specs(section, *, enabled_only=True): + yield ChannelInstanceSpec("default", section) + yield ChannelInstanceSpec("product", section) + + plugin = ChannelPlugin( + name="generated", + display_name="Generated", + runtime=f"{__name__}:_SingleChannel", + setup=ChannelSetupSpec(fields={}), + management=ChannelManagementSpec( + multi_instance=True, + instance_specs=generate_specs, + update_instance_config=lambda section, values, *, instance_id="default": values, + runtime_name=lambda name, instance_id: ( + name if instance_id == "default" else f"{name}.{instance_id}" + ), + ), + ) + + specs = channel_instance_specs(plugin, {"enabled": True}) + + assert [spec.instance_id for spec in specs] == ["default", "product"] + + +def test_single_instance_contract_preserves_plugin_owned_instances_field() -> None: + section = { + "enabled": True, + "instances": ["plugin-owned-value"], + } + + specs = channel_instance_specs(_SINGLE_PLUGIN, section) + + assert specs == [ChannelInstanceSpec("default", section)] + + +@pytest.mark.parametrize( + ("instance_ids", "message"), + [ + pytest.param( + ["default", "default"], + "duplicate instance id 'default'", + id="duplicate-instance-id", + ), + pytest.param( + ["default", "product"], + "duplicate runtime name 'invalid'", + id="duplicate-runtime-name", + ), + ], +) +def test_channel_instance_contract_rejects_invalid_specs(instance_ids, message) -> None: + plugin = ChannelPlugin( + name="invalid", + display_name="Invalid", + runtime=f"{__name__}:_SingleChannel", + setup=ChannelSetupSpec(fields={}), + management=ChannelManagementSpec( + multi_instance=True, + instance_specs=lambda section, *, enabled_only=True: [ + ChannelInstanceSpec(instance_id, {}) for instance_id in instance_ids + ], + update_instance_config=lambda section, values, *, instance_id="default": values, + runtime_name=lambda name, instance_id: name, + ), + ) + + with pytest.raises(ValueError, match=message): + channel_instance_specs(plugin, {"enabled": True}) + + +def test_channel_instance_contract_rejects_runtime_name_outside_namespace() -> None: + plugin = ChannelPlugin( + name="invalid", + display_name="Invalid", + runtime=f"{__name__}:_SingleChannel", + setup=ChannelSetupSpec(fields={}), + management=ChannelManagementSpec( + multi_instance=True, + instance_specs=lambda section, *, enabled_only=True: [ + ChannelInstanceSpec("default", section) + ], + update_instance_config=lambda section, values, *, instance_id="default": values, + runtime_name=lambda name, instance_id: "other", + ), + ) + + with pytest.raises(ValueError, match="must be scoped under 'invalid'"): + channel_instance_specs(plugin, {"enabled": True}) + + +def test_channel_setup_contract_owns_fields_and_validation() -> None: + spec = channel_setup_spec( + _SetupChannel.name, + plugin=_SETUP_PLUGIN, + ) + + assert spec is not None + assert spec.route_field_types == {"token": "secret"} + assert spec.is_configured({"token": "saved"}) is True + assert spec.validator is not None + assert spec.validator({"token": "saved"}, ChannelValidationContext())["status"] == "connected" + assert spec.to_public_dict(_SetupChannel.name) == { + "fields": [{ + "key": "channels.setup_contract.token", + "field": "token", + "kind": "secret", + "choices": [], + "required": True, + }], + } diff --git a/tests/channels/test_channel_manager_hot_reload.py b/tests/channels/test_channel_manager_hot_reload.py index 946ce997..d9c0f54c 100644 --- a/tests/channels/test_channel_manager_hot_reload.py +++ b/tests/channels/test_channel_manager_hot_reload.py @@ -6,7 +6,13 @@ import pytest from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.channels.contracts import ( + ChannelInstanceSpec, + ChannelManagementSpec, + ChannelSetupSpec, +) from nanobot.channels.manager import ChannelManager +from nanobot.channels.plugin import ChannelPlugin from nanobot.config.schema import Config @@ -32,6 +38,77 @@ class _HotChannel(BaseChannel): raise AssertionError("send should not be called") +class _MultiHotChannel(_HotChannel): + name = "multi" + display_name = "Multi" + +class _AliasHotChannel(_HotChannel): + """Package descriptor alias that claims another channel's runtime namespace.""" + + name = "hot" + display_name = "Alias" + + +def _multi_instance_specs(section, *, enabled_only=True): + instances = section.get("instances", []) if isinstance(section, dict) else [] + return [ + ChannelInstanceSpec( + instance_id=item["id"], + config=item, + ) + for item in instances + if not enabled_only or item.get("enabled", False) + ] + + +def _plugin(channel_cls: type[BaseChannel], *, multi_instance: bool = False) -> ChannelPlugin: + runtime_attr = f"_runtime_{channel_cls.display_name.lower()}" + globals()[runtime_attr] = channel_cls + setup = ChannelSetupSpec(fields={}) if multi_instance else None + management = ( + ChannelManagementSpec( + multi_instance=True, + instance_specs=_multi_instance_specs, + update_instance_config=lambda section, values, *, instance_id="default": values, + runtime_name=lambda name, instance_id: ( + name if instance_id == "default" else f"{name}.{instance_id}" + ), + ) + if multi_instance + else ChannelManagementSpec() + ) + return ChannelPlugin( + name=channel_cls.name, + display_name=channel_cls.display_name, + runtime=f"{__name__}:{runtime_attr}", + setup=setup, + management=management, + ) + + +def _stub_registry(monkeypatch, *plugins: ChannelPlugin) -> None: + by_name = {plugin.name: plugin for plugin in plugins} + monkeypatch.setattr( + "nanobot.channels.registry.discover_plugins", + lambda enabled_names=None: { + name: plugin + for name, plugin in by_name.items() + if enabled_names is None or name in enabled_names + }, + ) + + +def test_descriptor_rejects_runtime_class_owned_by_another_name(): + plugin = ChannelPlugin( + name="alias", + display_name="Alias", + runtime=f"{__name__}:_AliasHotChannel", + ) + + with pytest.raises(ImportError, match="runtime declares name 'hot'"): + plugin.load_channel_class() + + @pytest.mark.asyncio async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch): disabled = Config.model_validate({ @@ -47,15 +124,8 @@ async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch } }) - import nanobot.channels.registry as registry - - def discover_enabled(enabled_names, **_kwargs): - return {"hot": _HotChannel} if "hot" in enabled_names else {} - configs = iter([enabled, disabled]) - monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"]) - monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {}) - monkeypatch.setattr(registry, "discover_enabled", discover_enabled) + _stub_registry(monkeypatch, _plugin(_HotChannel)) monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs)) manager = ChannelManager(disabled, MessageBus()) @@ -86,15 +156,7 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f } }) - import nanobot.channels.registry as registry - - monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"]) - monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {}) - monkeypatch.setattr( - registry, - "discover_enabled", - lambda enabled_names, **_kwargs: {"hot": _HotChannel}, - ) + _stub_registry(monkeypatch, _plugin(_HotChannel)) monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled) manager = ChannelManager(enabled, MessageBus()) @@ -108,7 +170,99 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f result = await manager.apply_channel_feature_action("enable", "hot") - assert result["requires_restart"] is True + assert result["requires_restart"] is False + assert result["ok"] is False assert manager.channels["hot"] is old_channel assert old_channel.is_running is True assert not old_channel.stopped.is_set() + + +@pytest.mark.asyncio +async def test_apply_channel_feature_action_uses_channel_runtime_name(monkeypatch): + config = Config.model_validate({ + "channels": { + "websocket": {"enabled": False}, + "multi": { + "enabled": True, + "instances": [ + {"id": "default", "enabled": True}, + {"id": "product", "enabled": True}, + ] + }, + } + }) + + _stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True)) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) + + manager = ChannelManager(config, MessageBus()) + product = manager.channels["multi.product"] + product._running = True + + result = await manager.apply_channel_feature_action("disable", "multi", "product") + + assert result["requires_restart"] is False + assert "multi" in manager.channels + assert "multi.product" not in manager.channels + assert product.is_running is False + + +@pytest.mark.asyncio +async def test_default_multi_channel_action_reconciles_only_default_runtime(monkeypatch): + initial = Config.model_validate({ + "channels": { + "websocket": {"enabled": False}, + "multi": { + "enabled": True, + "instances": [ + {"id": "default", "enabled": True}, + {"id": "product", "enabled": True}, + ], + }, + } + }) + disabled = Config.model_validate({ + "channels": { + "websocket": {"enabled": False}, + "multi": { + "enabled": True, + "instances": [ + {"id": "default", "enabled": False}, + {"id": "product", "enabled": True}, + ], + }, + } + }) + enabled = Config.model_validate({ + "channels": { + "websocket": {"enabled": False}, + "multi": { + "enabled": True, + "instances": [ + {"id": "default", "enabled": True}, + {"id": "product", "enabled": True}, + ], + }, + } + }) + + _stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True)) + configs = iter([disabled, enabled]) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs)) + + manager = ChannelManager(initial, MessageBus()) + default = manager.channels["multi"] + product = manager.channels["multi.product"] + + disabled_result = await manager.apply_channel_feature_action("disable", "multi") + + assert disabled_result["requires_restart"] is False + assert set(manager.channels) == {"multi.product"} + assert default.stopped.is_set() + assert not product.stopped.is_set() + + enabled_result = await manager.apply_channel_feature_action("enable", "multi") + + assert enabled_result["requires_restart"] is False + assert set(manager.channels) == {"multi", "multi.product"} + assert manager.channels["multi.product"] is product diff --git a/tests/channels/test_channel_manager_reasoning.py b/tests/channels/test_channel_manager_reasoning.py index df593aa6..635ecc8e 100644 --- a/tests/channels/test_channel_manager_reasoning.py +++ b/tests/channels/test_channel_manager_reasoning.py @@ -124,6 +124,7 @@ async def test_reasoning_end_routes_to_send_reasoning_end(manager): ) await manager._send_once(channel, msg) channel._end_mock.assert_awaited_once() + assert channel._end_mock.await_args.kwargs["stream_id"] == "r1" channel._delta_mock.assert_not_awaited() diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index da15a08e..a77eb629 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -1,4 +1,4 @@ -"""Tests for channel plugin discovery, merging, and config compatibility.""" +"""Tests for channel package discovery, management, and config behavior.""" from __future__ import annotations @@ -7,6 +7,7 @@ import json import subprocess import sys import tomllib +from dataclasses import replace from importlib.metadata import PackageNotFoundError from pathlib import Path from types import SimpleNamespace @@ -16,7 +17,6 @@ import pytest from nanobot.bus.events import OutboundMessage from nanobot.bus.outbound_events import ( - ProgressEvent, StreamDeltaEvent, StreamedResponseEvent, StreamEndEvent, @@ -24,8 +24,17 @@ from nanobot.bus.outbound_events import ( ) from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.channels.contracts import ( + ChannelFieldSpec, + ChannelInstanceSpec, + ChannelManagementSpec, + ChannelSetupSpec, + SetupRequirement, + channel_default_config, +) from nanobot.channels.manager import ChannelManager -from nanobot.config.loader import save_config +from nanobot.channels.plugin import ChannelPlugin, load_channel_package +from nanobot.config.loader import load_config, save_config from nanobot.config.schema import ChannelsConfig, Config from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider @@ -57,24 +66,31 @@ class _FakePlugin(BaseChannel): return True -class _FakeTelegram(BaseChannel): - """Plugin that tries to shadow built-in telegram.""" - name = "telegram" - display_name = "Fake Telegram" +class _SetupPlugin(_FakePlugin): + name = "setupplugin" + display_name = "Setup Plugin" - async def start(self) -> None: - pass - - async def stop(self) -> None: - pass - - async def send(self, msg: OutboundMessage) -> None: - pass + @staticmethod + def _validate_setup(values, _context): + token = str(values.get("token") or "") + return { + "status": "connected" if token.startswith("plugin-") else "invalid", + "checks": [{ + "id": "plugin", + "label": "Plugin validation", + "status": "pass" if token.startswith("plugin-") else "fail", + }], + } -class _FakeFeishu(BaseChannel): - name = "feishu" - display_name = "Feishu" +class _FakeLine(_FakePlugin): + name = "line" + display_name = "Line" + + +class _FakeMultiChannel(BaseChannel): + name = "multi" + display_name = "Multi" @classmethod def default_config(cls) -> dict: @@ -82,12 +98,7 @@ class _FakeFeishu(BaseChannel): "instanceId": "default", "name": "nanobot", "enabled": False, - "appId": "", - "appSecret": "", - "domain": "feishu", - "groupPolicy": "mention", - "topicIsolation": True, - "allowFrom": [], + "token": "", } async def start(self) -> None: @@ -100,10 +111,108 @@ class _FakeFeishu(BaseChannel): pass -def _make_entry_point(name: str, cls: type): - """Create a mock entry point that returns *cls* on load().""" - ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls) - return ep +def _fake_multi_instance_specs(section, *, enabled_only=True): + instances = section.get("instances", []) if isinstance(section, dict) else [] + return [ + ChannelInstanceSpec( + instance_id=item["id"], + config=item, + ) + for item in instances + if not enabled_only or item.get("enabled", False) + ] + + +def _fake_multi_update(section, values, *, instance_id="default"): + updated = dict(section) + instances = [dict(item) for item in section.get("instances", [])] + for item in instances: + if item.get("id") == instance_id: + item.update(values) + break + updated["instances"] = instances + return updated + + +def _fake_multi_management() -> ChannelManagementSpec: + return ChannelManagementSpec( + multi_instance=True, + default_config=_FakeMultiChannel.default_config, + instance_specs=_fake_multi_instance_specs, + update_instance_config=_fake_multi_update, + runtime_name=lambda name, instance_id: ( + name if instance_id == "default" else f"{name}.{instance_id}" + ), + ) + + +def _channel_plugin( + channel_cls: type[BaseChannel], + *, + setup: ChannelSetupSpec | None = None, + dependencies: tuple[str, ...] = (), + default_enabled: bool = False, + management: ChannelManagementSpec | None = None, +) -> ChannelPlugin: + """Create a descriptor whose lazy runtime resolves inside this test module.""" + runtime_attr = f"_runtime_{channel_cls.name.replace('-', '_')}" + globals()[runtime_attr] = channel_cls + if management is None: + management = ( + _fake_multi_management() + if issubclass(channel_cls, _FakeMultiChannel) + else ChannelManagementSpec(default_config=channel_cls.default_config) + ) + if setup is None and management.multi_instance: + setup = ChannelSetupSpec(fields={}) + return ChannelPlugin( + name=channel_cls.name, + display_name=channel_cls.display_name, + runtime=f"{__name__}:{runtime_attr}", + setup=setup, + management=management, + dependencies=dependencies, + default_enabled=default_enabled, + ) + + +_SETUP_PLUGIN_SPEC = ChannelSetupSpec( + fields={ + "token": ChannelFieldSpec(kind="secret"), + "region": ChannelFieldSpec( + kind="enum", + choices=frozenset({"us", "eu"}), + ), + }, + required=(SetupRequirement((("token",),)),), + official_url="https://plugin.example/setup", + validator=_SetupPlugin._validate_setup, +) + + +def _stub_channel_registry( + monkeypatch: pytest.MonkeyPatch, + *plugins: ChannelPlugin, +) -> None: + by_name = {plugin.name: plugin for plugin in plugins} + + def discover(enabled_names=None): + if enabled_names is None: + return dict(by_name) + return {name: plugin for name, plugin in by_name.items() if name in enabled_names} + + monkeypatch.setattr("nanobot.channels.registry.discover_plugins", discover) + + +def _stub_channel_packages( + monkeypatch: pytest.MonkeyPatch, + *names: str, +) -> None: + from nanobot.channels.plugin import load_channel_package + + plugins = [load_channel_package(name) for name in names] + assert all(plugin is not None for plugin in plugins) + _stub_channel_registry(monkeypatch, *(plugin for plugin in plugins if plugin is not None)) def _stub_optional_feature_cli( @@ -115,10 +224,16 @@ def _stub_optional_feature_cli( channels: list[str] | None = None, channel_cls: type[BaseChannel] | None = None, ) -> None: - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: channels or []) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + plugins = [] if channel_cls is not None: - monkeypatch.setattr("nanobot.channels.registry.load_channel_class", lambda _name: channel_cls) + plugins.append( + _channel_plugin( + channel_cls, + dependencies=tuple(extras.get(channel_cls.name) or ()), + ) + ) + assert not channels or {plugin.name for plugin in plugins} == set(channels) + _stub_channel_registry(monkeypatch, *plugins) monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: extras) monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: installed) if commands is not None: @@ -149,7 +264,7 @@ def test_channels_config_getattr_returns_extra(): assert section["enabled"] is True -def test_channels_config_builtin_fields_removed(): +def test_channels_config_has_no_per_channel_fields(): """After decoupling, ChannelsConfig has no explicit channel fields.""" cfg = ChannelsConfig() assert not hasattr(cfg, "telegram") @@ -164,36 +279,62 @@ def test_channels_config_extract_document_text_accepts_camel_alias(): assert cfg.extract_document_text is False -def test_channel_manager_expands_feishu_instances(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"]) - monkeypatch.setattr( - "nanobot.channels.registry.discover_enabled", - lambda enabled, _names=None, warn_import_errors=True: {"feishu": _FakeFeishu} - if "feishu" in enabled - else {}, - ) +@pytest.mark.parametrize( + "name", + ["websocket", "telegram", "discord", "slack", "email", "feishu", "matrix", "weixin", "whatsapp"], +) +def test_special_setup_validation_is_owned_by_channel_package(name: str): + plugin = load_channel_package(name) + + assert plugin is not None + assert plugin.setup is not None + assert plugin.setup.validator is not None + assert plugin.setup.validator.__module__ == f"nanobot.channels.{name}.validation" + + +@pytest.mark.parametrize("name", ["feishu", "weixin"]) +def test_interactive_connector_is_owned_by_channel_package(name: str): + plugin = load_channel_package(name) + + assert plugin is not None + assert plugin.connector is not None + assert plugin.connector.startswith(f"nanobot.channels.{name}.") + assert plugin.load_connector().__class__.__module__ == f"nanobot.channels.{name}.connect" + + +def test_descriptor_defaults_cover_onboarding_fields_without_runtime_import(): + qq = load_channel_package("qq") + email = load_channel_package("email") + + assert qq is not None + assert email is not None + assert channel_default_config(qq)["msgFormat"] == "plain" + assert channel_default_config(email)["imapPort"] == 993 + assert channel_default_config(email)["smtpPort"] == 587 + + +def test_channel_manager_delegates_instance_expansion_to_channel(monkeypatch: pytest.MonkeyPatch): + _stub_channel_registry(monkeypatch, _channel_plugin(_FakeMultiChannel)) cfg = Config.model_validate({ "channels": { - "feishu": { + "multi": { + "enabled": True, "instances": [ { "id": "default", "enabled": True, - "appId": "cli_default", - "appSecret": "secret", + "token": "default", }, { "id": "product", "enabled": True, - "appId": "cli_product", - "appSecret": "secret", + "token": "product", }, { "id": "off", "enabled": False, - "appId": "cli_off", - "appSecret": "secret", + "token": "off", }, ] } @@ -202,27 +343,427 @@ def test_channel_manager_expands_feishu_instances(monkeypatch: pytest.MonkeyPatc manager = ChannelManager(cfg, MessageBus()) - assert set(manager.channels) == {"feishu", "feishu.product"} - assert manager.channels["feishu"].name == "feishu" - assert manager.channels["feishu.product"].name == "feishu.product" + assert set(manager.channels) == {"multi", "multi.product"} + assert manager.channels["multi"].name == "multi" + assert manager.channels["multi.product"].name == "multi.product" + + +def test_channel_manager_loads_descriptor_but_not_disabled_runtime(monkeypatch): + load_calls: list[str] = [] + plugin = ChannelPlugin( + name="fakeplugin", + display_name="Fake Plugin", + runtime="missing.fakeplugin.runtime:FakePlugin", + ) + config = Config.model_validate({ + "channels": { + "fakeplugin": { + "enabled": False, + "instances": [{"enabled": True}], + } + } + }) + + monkeypatch.setattr( + "nanobot.channels.registry._channel_package_names", + lambda: ["fakeplugin"], + ) + monkeypatch.setattr( + "nanobot.channels.registry.load_channel_package", + lambda _name: load_calls.append("descriptor") or plugin, + ) + + manager = ChannelManager(config, MessageBus()) + + assert manager.channels == {} + assert load_calls == ["descriptor"] + + +def test_feature_payload_uses_unified_instance_activation(monkeypatch): + from nanobot.optional_features import optional_features_payload + + config = Config.model_validate({ + "channels": { + "multi": { + "enabled": False, + "instances": [{"id": "default", "enabled": True}], + } + } + }) + _stub_channel_registry(monkeypatch, _channel_plugin(_FakeMultiChannel)) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + payload = optional_features_payload(config=config) + + assert payload["features"][0]["enabled"] is True + assert payload["features"][0]["ready"] is True + assert payload["features"][0]["status"] == "enabled" + assert payload["enabled_count"] == 1 + + +def test_multi_plugin_action_defaults_to_default_instance( + monkeypatch, + tmp_path, +): + from nanobot.config import loader + from nanobot.webui.nanobot_features_api import nanobot_features_action + + class _ManagedMultiPlugin(_FakeMultiChannel): + name = "managedmulti" + + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({ + "channels": { + "managedmulti": { + "enabled": True, + "instances": [ + {"id": "default", "enabled": True, "token": "default"}, + {"id": "product", "enabled": True, "token": "product"}, + ], + } + } + }), + encoding="utf-8", + ) + monkeypatch.setattr(loader, "_current_config_path", config_path) + _stub_channel_registry( + monkeypatch, + _channel_plugin(_ManagedMultiPlugin, management=_fake_multi_management()), + ) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + disabled = nanobot_features_action("disable", {"name": ["managedmulti"]}) + saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["managedmulti"] + assert saved["enabled"] is True + assert [item["enabled"] for item in saved["instances"]] == [False, True] + assert disabled["features"][0]["enabled"] is True + + enabled = nanobot_features_action("enable", {"name": ["managedmulti"]}) + saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["managedmulti"] + assert saved["enabled"] is True + assert [item["enabled"] for item in saved["instances"]] == [True, True] + assert enabled["features"][0]["enabled"] is True + + explicit = nanobot_features_action( + "disable", + {"name": ["managedmulti"], "instance_id": ["default"]}, + ) + saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["managedmulti"] + assert saved["enabled"] is True + assert [item["enabled"] for item in saved["instances"]] == [False, True] + assert explicit["features"][0]["enabled"] is True + + +async def test_single_channel_enable_applies_defaults_before_hot_reload( + monkeypatch, + tmp_path, +): + from nanobot.config import loader + from nanobot.webui.nanobot_features_api import nanobot_features_action + + class _SingleDefaultsPlugin(_FakePlugin): + name = "singleplugin" + + @classmethod + def default_config(cls): + return { + "enabled": False, + "endpoint": "https://plugin.example/api", + "retries": 3, + } + + def __init__(self, config, bus): + super().__init__(config, bus) + self.endpoint = config["endpoint"] + self.retries = config["retries"] + + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"channels": {"singleplugin": {"enabled": False}}}), + encoding="utf-8", + ) + monkeypatch.setattr(loader, "_current_config_path", config_path) + _stub_channel_registry(monkeypatch, _channel_plugin(_SingleDefaultsPlugin)) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + manager = ChannelManager( + Config.model_validate({"channels": {"singleplugin": {"enabled": False}}}), + MessageBus(), + ) + + payload = nanobot_features_action("enable", {"name": ["singleplugin"]}) + hot_reload = await manager.apply_channel_feature_action("enable", "singleplugin") + + saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["singleplugin"] + assert saved == { + "enabled": True, + "endpoint": "https://plugin.example/api", + "retries": 3, + } + assert payload["features"][0]["enabled"] is True + assert hot_reload["ok"] is True + assert hot_reload["requires_restart"] is False + assert set(manager.channels) == {"singleplugin"} + assert manager.channels["singleplugin"].endpoint == "https://plugin.example/api" + assert manager.channels["singleplugin"].retries == 3 + + +def test_channel_manager_preserves_single_instance_plugin_owned_instances(monkeypatch): + _stub_channel_registry(monkeypatch, _channel_plugin(_FakePlugin)) + config = Config.model_validate({ + "channels": { + "fakeplugin": { + "enabled": True, + "instances": ["plugin-owned-value"], + } + } + }) + + manager = ChannelManager(config, MessageBus()) + + assert set(manager.channels) == {"fakeplugin"} + assert manager.channels["fakeplugin"].config["instances"] == ["plugin-owned-value"] # --------------------------------------------------------------------------- -# discover_plugins +# Channel package discovery # --------------------------------------------------------------------------- -_EP_TARGET = "importlib.metadata.entry_points" - - -def test_discover_plugins_loads_entry_points(): +def test_discover_plugins_loads_package_descriptors(): from nanobot.channels.registry import discover_plugins - ep = _make_entry_point("line", _FakePlugin) - with patch(_EP_TARGET, return_value=[ep]): + plugin = _channel_plugin(_FakeLine) + with ( + patch("nanobot.channels.registry._channel_package_names", return_value=["line"]), + patch("nanobot.channels.registry.load_channel_package", return_value=plugin), + ): result = discover_plugins() assert "line" in result - assert result["line"] is _FakePlugin + assert isinstance(result["line"], ChannelPlugin) + + +def test_plugin_setup_contract_drives_feature_payload(monkeypatch: pytest.MonkeyPatch): + from nanobot.optional_features import optional_features_payload + + config = Config.model_validate({ + "channels": { + "setupplugin": { + "enabled": False, + "token": "plugin-secret", + "region": "eu", + } + } + }) + _stub_channel_registry( + monkeypatch, + _channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC), + ) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + payload = optional_features_payload(config=config) + + feature = payload["features"][0] + assert feature["configured"] is True + assert feature["setup"] == { + "fields": [ + { + "key": "channels.setupplugin.token", + "field": "token", + "kind": "secret", + "choices": [], + "required": True, + }, + { + "key": "channels.setupplugin.region", + "field": "region", + "kind": "enum", + "choices": ["eu", "us"], + "required": False, + }, + ], + "official_url": "https://plugin.example/setup", + } + assert feature["configured_fields"] == [ + "channels.setupplugin.token", + "channels.setupplugin.region", + ] + assert feature["config_values"] == {"channels.setupplugin.region": "eu"} + + +def test_plugin_contract_error_is_isolated_in_feature_payload(monkeypatch): + from nanobot.optional_features import optional_features_payload + + class _BrokenPlugin(_FakePlugin): + name = "broken" + display_name = "Broken" + + def broken_instance_specs(section, *, enabled_only=True): + raise ValueError("malformed plugin instance config") + + config = Config.model_validate({ + "channels": { + "broken": {"enabled": True}, + "setupplugin": {"enabled": False, "token": "plugin-secret"}, + } + }) + _stub_channel_registry( + monkeypatch, + _channel_plugin( + _BrokenPlugin, + management=ChannelManagementSpec( + multi_instance=True, + instance_specs=broken_instance_specs, + update_instance_config=lambda section, values, *, instance_id="default": values, + ), + ), + _channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC), + ) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + payload = optional_features_payload(config=config) + + features = {feature["name"]: feature for feature in payload["features"]} + assert features["broken"] == { + "name": "broken", + "display_name": "Broken", + "type": "channel", + "capabilities": [], + "settings_visible": True, + "setup": {"fields": []}, + "enabled": False, + "configured": False, + "installed": True, + "ready": False, + "status": "invalid_config", + "install_supported": True, + "requires_restart": True, + "error": "Channel configuration could not be inspected.", + } + assert features["setupplugin"]["configured"] is True + + +def test_plugin_setup_contract_drives_save_and_validation( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + from nanobot.channels.validation import validate_channel_config + from nanobot.config import loader + from nanobot.webui.settings_routes import WebUISettingsRouter + + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr(loader, "_current_config_path", config_path) + _stub_channel_registry( + monkeypatch, + _channel_plugin(_SetupPlugin, setup=_SETUP_PLUGIN_SPEC), + ) + router = object.__new__(WebUISettingsRouter) + + saved = router._save_channel_config_values( + "setupplugin", + { + "channels.setupplugin.token": "plugin-secret", + "channels.setupplugin.region": "eu", + }, + ) + validation = validate_channel_config("setupplugin") + + assert saved == [ + "channels.setupplugin.token", + "channels.setupplugin.region", + ] + assert load_config(config_path).channels.setupplugin["token"] == "plugin-secret" + assert validation["status"] == "connected" + assert validation["checks"][0]["id"] == "plugin" + + +def test_generic_plugin_validation_enforces_composite_requirements( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from nanobot.channels.validation import validate_channel_config + from nanobot.config import loader + + class _CompositeSetupPlugin(_FakePlugin): + name = "compositeplugin" + + setup_spec = ChannelSetupSpec( + fields={ + "password": ChannelFieldSpec(kind="secret"), + "accessToken": ChannelFieldSpec(kind="secret"), + "deviceId": ChannelFieldSpec(), + }, + required=( + SetupRequirement.one_of( + ("password",), + ("accessToken", "deviceId"), + ), + ), + ) + + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr(loader, "_current_config_path", config_path) + _stub_channel_registry( + monkeypatch, + _channel_plugin(_CompositeSetupPlugin, setup=setup_spec), + ) + + missing = validate_channel_config("compositeplugin") + partial = validate_channel_config( + "compositeplugin", + {"channels.compositeplugin.accessToken": "token"}, + ) + complete = validate_channel_config( + "compositeplugin", + { + "channels.compositeplugin.accessToken": "token", + "channels.compositeplugin.deviceId": "DEVICE", + }, + ) + + assert missing["status"] == "needs_setup" + assert missing["can_enable"] is False + assert "password" in missing["missing_fields"] + assert partial["status"] == "needs_setup" + assert partial["can_enable"] is False + assert "deviceId" in partial["missing_fields"] + assert complete["status"] == "configured" + assert complete["can_enable"] is True + + +def test_webui_save_rejects_duplicate_feishu_ids_without_writing(monkeypatch, tmp_path): + from nanobot.config import loader + from nanobot.webui.settings_api import WebUISettingsError + from nanobot.webui.settings_routes import WebUISettingsRouter + + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({ + "channels": { + "feishu": { + "instances": [ + {"id": "default", "enabled": True, "appId": "A"}, + {"id": "default", "enabled": False, "appId": "B"}, + ] + } + } + }), + encoding="utf-8", + ) + before = config_path.read_text(encoding="utf-8") + monkeypatch.setattr(loader, "_current_config_path", config_path) + router = object.__new__(WebUISettingsRouter) + + with pytest.raises(WebUISettingsError, match="duplicate Feishu instance id 'default'") as error: + router._save_channel_config_values( + "feishu", + {"channels.feishu.appId": "updated"}, + ) + + assert error.value.status == 400 + assert config_path.read_text(encoding="utf-8") == before def test_discover_plugins_skips_names_outside_enabled_set(): @@ -230,52 +771,101 @@ def test_discover_plugins_skips_names_outside_enabled_set(): loaded: list[str] = [] - def _load_disabled(): + def _load_disabled(_name: str): loaded.append("disabled") - return _FakePlugin + return _channel_plugin(_FakePlugin) - ep = SimpleNamespace(name="disabled", load=_load_disabled) - with patch(_EP_TARGET, return_value=[ep]): + with ( + patch("nanobot.channels.registry._channel_package_names", return_value=["disabled"]), + patch("nanobot.channels.registry.load_channel_package", side_effect=_load_disabled), + ): result = discover_plugins({"enabled"}) assert result == {} assert loaded == [] +def test_discover_plugins_warns_once_for_legacy_entry_points(): + from nanobot.channels.registry import _warn_legacy_channel_entry_points, discover_plugins + + legacy_entry_points = [SimpleNamespace(name="z-old"), SimpleNamespace(name="a-old")] + _warn_legacy_channel_entry_points.cache_clear() + try: + with ( + patch( + "nanobot.channels.registry.entry_points", + return_value=legacy_entry_points, + ) as metadata_entry_points, + patch("nanobot.channels.registry._channel_package_names", return_value=[]), + patch("nanobot.channels.registry.logger.warning") as warning, + ): + discover_plugins() + discover_plugins() + finally: + _warn_legacy_channel_entry_points.cache_clear() + + metadata_entry_points.assert_called_once_with(group="nanobot.channels") + warning.assert_called_once_with( + "Legacy channel entry points were detected but will not be loaded: {}. " + "The '{}' entry-point group is no longer supported; use a built-in channel or " + "migrate it into nanobot/channels//.", + "a-old, z-old", + "nanobot.channels", + ) + + +def test_channel_manifest_rejects_invalid_dependency_metadata(): + with pytest.raises(TypeError, match="tuple of requirements"): + ChannelPlugin( + name="broken", + display_name="Broken", + runtime="broken.runtime:BrokenChannel", + dependencies=["broken-sdk>=1"], # type: ignore[arg-type] + ) + with pytest.raises(ValueError, match="valid requirement"): + ChannelPlugin( + name="broken", + display_name="Broken", + runtime="broken.runtime:BrokenChannel", + dependencies=("not a requirement ???",), + ) + + def test_discover_plugins_handles_load_error(): from nanobot.channels.registry import discover_plugins - def _boom(): + def _boom(_name: str): raise RuntimeError("broken") - ep = SimpleNamespace(name="broken", load=_boom) - with patch(_EP_TARGET, return_value=[ep]): + with ( + patch("nanobot.channels.registry._channel_package_names", return_value=["broken"]), + patch("nanobot.channels.registry.load_channel_package", side_effect=_boom), + ): result = discover_plugins() assert "broken" not in result # --------------------------------------------------------------------------- -# discover_all — merge & priority +# Runtime discovery # --------------------------------------------------------------------------- -def test_discover_all_includes_builtins(): - from nanobot.channels.registry import discover_all, discover_channel_names +def test_discover_all_includes_available_channel_packages(): + from nanobot.channels.registry import discover_all, discover_plugins - with patch(_EP_TARGET, return_value=[]): - result = discover_all() + result = discover_all() # discover_all() only returns channels that are actually available (dependencies installed) - # discover_channel_names() returns all built-in channel names + # discover_plugins() returns all channel package descriptors # So we check that all actually loaded channels are in the result for name in result: - assert name in discover_channel_names() + assert name in discover_plugins() -def test_discover_channel_names_excludes_internal_helpers(): - from nanobot.channels.registry import discover_channel_names +def test_discover_plugins_excludes_internal_helpers(): + from nanobot.channels.registry import discover_plugins - names = discover_channel_names() + names = discover_plugins() assert "_feishu_ws" not in names assert "_setup" not in names @@ -283,109 +873,140 @@ def test_discover_channel_names_excludes_internal_helpers(): assert "_feishu_instances" not in names -def test_discover_all_includes_external_plugin(): - from nanobot.channels.registry import discover_all - - ep = _make_entry_point("line", _FakePlugin) - with patch(_EP_TARGET, return_value=[ep]): - result = discover_all() - - assert "line" in result - assert result["line"] is _FakePlugin - - -def test_discover_enabled_imports_only_enabled_builtins(): +def test_discover_enabled_imports_only_enabled_packages(): from nanobot.channels.registry import discover_enabled - loaded: list[str] = [] + class _EnabledPlugin(_FakePlugin): + name = "enabled" - def _load_channel(name: str): - loaded.append(name) - return _FakePlugin + plugins = { + "enabled": _channel_plugin(_EnabledPlugin), + "disabled": ChannelPlugin( + name="disabled", + display_name="Disabled", + runtime="missing.disabled.runtime:DisabledPlugin", + ), + } - with ( - patch("nanobot.channels.registry.load_channel_class", side_effect=_load_channel), - patch(_EP_TARGET, return_value=[]), - ): - result = discover_enabled({"enabled"}, _names=["enabled", "disabled"]) + result = discover_enabled({"enabled"}, _plugins=plugins) - assert result == {"enabled": _FakePlugin} - assert loaded == ["enabled"] + assert result == {"enabled": _EnabledPlugin} -def test_discover_enabled_warns_for_enabled_builtin_import_errors(): +def test_discover_enabled_warns_for_enabled_package_import_errors(): from nanobot.channels.registry import discover_enabled - with ( - patch("nanobot.channels.registry.load_channel_class", side_effect=ImportError("missing sdk")), - patch(_EP_TARGET, return_value=[]), - patch("nanobot.channels.registry.logger.warning") as warning, - ): - result = discover_enabled({"matrix"}, _names=["matrix"], warn_import_errors=True) + plugin = ChannelPlugin( + name="matrix", + display_name="Matrix", + runtime="missing.matrix.runtime:MatrixChannel", + ) + with patch("nanobot.channels.registry.logger.warning") as warning: + result = discover_enabled( + {"matrix"}, + _plugins={"matrix": plugin}, + warn_import_errors=True, + ) assert result == {} warning.assert_called_once() - assert warning.call_args.args[0] == "Enabled built-in channel '{}' is not available: {}" + assert warning.call_args.args[0] == "Enabled channel '{}' runtime is not available: {}" assert warning.call_args.args[1] == "matrix" - assert "missing sdk" in str(warning.call_args.args[2]) - - -def test_discover_all_builtin_shadows_plugin(): - from nanobot.channels.registry import discover_all - - ep = _make_entry_point("telegram", _FakeTelegram) - with patch(_EP_TARGET, return_value=[ep]): - result = discover_all() - - assert "telegram" in result - assert result["telegram"] is not _FakeTelegram - - -def test_discover_all_builtin_name_shadows_plugin_when_dependency_missing(): - from nanobot.channels.registry import discover_all - - ep = _make_entry_point("telegram", _FakeTelegram) - with ( - patch("nanobot.channels.registry.discover_channel_names", return_value=["telegram"]), - patch("nanobot.channels.registry.load_channel_class", side_effect=ImportError("missing")), - patch(_EP_TARGET, return_value=[ep]), - ): - result = discover_all() - - assert "telegram" not in result + assert "missing" in str(warning.call_args.args[2]) # --------------------------------------------------------------------------- -# Manager _init_channels with dict config (plugin scenario) +# Manager _init_channels with dict config # --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_manager_loads_plugin_from_dict_config(): - """ChannelManager should instantiate a plugin channel from a raw dict config.""" +def test_manager_loads_plugin_from_dict_config(monkeypatch): + """ChannelManager should instantiate a channel package from a raw dict config.""" from nanobot.channels.manager import ChannelManager - fake_config = SimpleNamespace( - channels=ChannelsConfig.model_validate({ + fake_config = Config.model_validate({ + "channels": { "fakeplugin": {"enabled": True, "allowFrom": ["*"]}, - }), - providers=SimpleNamespace(groq=SimpleNamespace(api_key="", api_base="")), - ) + } + }) + _stub_channel_registry(monkeypatch, _channel_plugin(_FakePlugin)) - with patch( - "nanobot.channels.registry.discover_enabled", - return_value={"fakeplugin": _FakePlugin}, - ): - mgr = ChannelManager.__new__(ChannelManager) - mgr.config = fake_config - mgr.bus = MessageBus() - mgr.channels = {} - mgr._dispatch_task = None - mgr._init_channels() + mgr = ChannelManager(fake_config, MessageBus()) assert "fakeplugin" in mgr.channels assert isinstance(mgr.channels["fakeplugin"], _FakePlugin) +def test_manager_installs_manifest_dependencies_before_loading_enabled_channel(monkeypatch): + from nanobot.optional_features import InstallResult + + plugin = _channel_plugin( + _FakePlugin, + dependencies=("fake-sdk>=1",), + ) + _stub_channel_registry(monkeypatch, plugin) + installed = False + installs: list[tuple[str, list[str]]] = [] + + def extra_installed(_name: str, _dependencies: list[str] | None) -> bool: + return installed + + def install_extra(name: str, dependencies: list[str], *, runner): + nonlocal installed + installs.append((name, dependencies)) + installed = True + return InstallResult(True, name, ["pip"]) + + monkeypatch.setattr("nanobot.optional_features.extra_installed", extra_installed) + monkeypatch.setattr("nanobot.optional_features.install_extra", install_extra) + config = Config.model_validate({ + "channels": { + "websocket": {"enabled": False}, + "fakeplugin": {"enabled": True}, + } + }) + + manager = ChannelManager(config, MessageBus()) + + assert installs == [("fakeplugin", ["fake-sdk>=1"])] + assert "fakeplugin" in manager.channels + + +def test_manager_reports_dependency_install_failure_as_runtime_failure(monkeypatch): + from nanobot.optional_features import InstallResult + + plugin = _channel_plugin( + _FakePlugin, + dependencies=("fake-sdk>=1",), + ) + _stub_channel_registry(monkeypatch, plugin) + monkeypatch.setattr( + "nanobot.optional_features.extra_installed", + lambda _name, _dependencies: False, + ) + monkeypatch.setattr( + "nanobot.optional_features.install_extra", + lambda name, _dependencies, *, runner: InstallResult(False, name, ["pip"]), + ) + config = Config.model_validate({ + "channels": { + "websocket": {"enabled": False}, + "fakeplugin": {"enabled": True}, + } + }) + + manager = ChannelManager(config, MessageBus()) + + assert manager.channels == {} + assert manager.get_status()["fakeplugin"] == { + "enabled": True, + "running": False, + "state": "failed", + "owner": "fakeplugin", + "instance_id": "default", + "error": "Channel dependencies could not be installed. Check gateway logs.", + } + + def test_manager_loads_websocket_from_default_config(): from nanobot.channels.manager import ChannelManager @@ -397,19 +1018,15 @@ def test_manager_loads_websocket_from_default_config(): super().__init__(config, bus) self.gateway = gateway - seen_enabled: set[str] = set() + @classmethod + def default_config(cls): + return {"enabled": True, "host": "127.0.0.1"} - def _discover_enabled(enabled_names: set[str], _names=None, warn_import_errors: bool = False): - seen_enabled.update(enabled_names) - return {"websocket": _FakeWebSocket} if "websocket" in enabled_names else {} - - with ( - patch("nanobot.channels.registry.discover_channel_names", return_value=["websocket"]), - patch("nanobot.channels.registry.discover_enabled", side_effect=_discover_enabled), - ): + plugin = _channel_plugin(_FakeWebSocket, default_enabled=True) + with patch("nanobot.channels.registry.discover_plugins", return_value={"websocket": plugin}): mgr = ChannelManager(Config(), MessageBus(), webui_static_dist=False) - assert "websocket" in seen_enabled + assert "websocket" in mgr.channels assert mgr.channels["websocket"].config["enabled"] is True assert mgr.channels["websocket"].config["host"] == "127.0.0.1" @@ -417,20 +1034,16 @@ def test_manager_loads_websocket_from_default_config(): def test_manager_respects_explicitly_disabled_websocket_config(): from nanobot.channels.manager import ChannelManager - seen_enabled: set[str] = set() - - def _discover_enabled(enabled_names: set[str], _names=None, warn_import_errors: bool = False): - seen_enabled.update(enabled_names) - return {} - config = Config.model_validate({"channels": {"websocket": {"enabled": False}}}) - with ( - patch("nanobot.channels.registry.discover_channel_names", return_value=["websocket"]), - patch("nanobot.channels.registry.discover_enabled", side_effect=_discover_enabled), - ): + plugin = ChannelPlugin( + name="websocket", + display_name="WebSocket", + runtime="missing.websocket.runtime:WebSocketChannel", + default_enabled=True, + ) + with patch("nanobot.channels.registry.discover_plugins", return_value={"websocket": plugin}): mgr = ChannelManager(config, MessageBus(), webui_static_dist=False) - assert "websocket" not in seen_enabled assert "websocket" not in mgr.channels @@ -708,8 +1321,7 @@ def test_plugins_list_shows_available_features(monkeypatch): runner = CliRunner() config = Config.model_validate({"channels": {"weixin": {"enabled": True}}}) monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: config) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "weixin") monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"weixin": ["qrcode[pil]>=8.0"], "bedrock": ["boto3>=1.43.0"]}, @@ -726,6 +1338,36 @@ def test_plugins_list_shows_available_features(monkeypatch): assert " - " not in result.stdout +def test_plugins_list_reads_multi_instance_state_without_runtime(monkeypatch): + from typer.testing import CliRunner + + from nanobot.cli.commands import app + + plugin = ChannelPlugin( + name="managedmulti", + display_name="Managed multi", + runtime="missing.managedmulti.runtime:ManagedMultiChannel", + setup=ChannelSetupSpec(fields={}), + management=_fake_multi_management(), + ) + config = Config.model_validate({ + "channels": { + "managedmulti": { + "instances": [{"id": "default", "enabled": True}], + } + } + }) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: config) + _stub_channel_registry(monkeypatch, plugin) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + result = CliRunner().invoke(app, ["plugins", "list"]) + + assert result.exit_code == 0 + assert "managedmulti" in result.stdout + assert "yes" in result.stdout + + def test_plugins_enable_channel_installs_extra_and_writes_config(monkeypatch, tmp_path): from typer.testing import CliRunner @@ -868,8 +1510,7 @@ def test_plugins_disable_channel_writes_config(monkeypatch, tmp_path): encoding="utf-8", ) runner = CliRunner() - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "matrix") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) result = runner.invoke(app, ["plugins", "disable", "matrix", "--config", str(config_path)]) @@ -888,11 +1529,7 @@ def test_plugins_disable_rejects_non_channel_and_allows_websocket(monkeypatch, t config_path = tmp_path / "config.json" runner = CliRunner() - monkeypatch.setattr( - "nanobot.channels.registry.discover_channel_names", - lambda: ["matrix", "websocket"], - ) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "matrix", "websocket") monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"bedrock": ["boto3>=1.43.0"]}, @@ -921,8 +1558,7 @@ def test_enable_optional_feature_blocks_install_when_disallowed(monkeypatch, tmp config_path = tmp_path / "config.json" monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: []) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_registry(monkeypatch) monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"bedrock": ["boto3>=1.43.0"]}, @@ -946,8 +1582,7 @@ def test_enable_optional_feature_skips_install_when_dependency_present( config_path = tmp_path / "config.json" install_calls: list[str] = [] monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: []) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_registry(monkeypatch) monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"bedrock": ["boto3>=1.43.0"]}, @@ -978,8 +1613,7 @@ def test_enable_optional_feature_lazy_reader_does_not_require_restart(monkeypatc config_path = tmp_path / "config.json" monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: []) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_registry(monkeypatch) monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"documents": ["pypdf>=5.0.0,<6.0.0"]}, @@ -1001,8 +1635,7 @@ def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path): config_path = tmp_path / "config.json" monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: []) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_registry(monkeypatch) monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"bedrock": ["boto3>=1.43.0"]}, @@ -1036,11 +1669,7 @@ def test_disable_optional_feature_rejects_unknown_features_and_non_channels( config_path = tmp_path / "config.json" monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - monkeypatch.setattr( - "nanobot.channels.registry.discover_channel_names", - lambda: ["matrix", "websocket"], - ) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "matrix", "websocket") monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"bedrock": ["boto3>=1.43.0"]}, @@ -1068,8 +1697,7 @@ def test_disable_optional_feature_writes_channel_disabled(monkeypatch, tmp_path) json.dumps({"channels": {"matrix": {"enabled": True, "homeserver": "keep"}}}), encoding="utf-8", ) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix", "websocket"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "matrix", "websocket") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = disable_optional_feature("matrix", config_path=config_path) @@ -1086,14 +1714,83 @@ def test_disable_optional_feature_writes_channel_disabled(monkeypatch, tmp_path) assert payload["last_action"]["message"] == "Disabled channel 'websocket'" +def test_disable_multi_instance_channel_without_importing_runtime(monkeypatch, tmp_path): + from nanobot.optional_features import disable_optional_feature + + plugin = ChannelPlugin( + name="managedmulti", + display_name="Managed multi", + runtime="missing.managedmulti.runtime:ManagedMultiChannel", + setup=ChannelSetupSpec(fields={}), + management=_fake_multi_management(), + ) + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({ + "channels": { + "managedmulti": { + "instances": [ + {"id": "default", "enabled": True}, + {"id": "product", "enabled": True}, + ] + } + } + }), + encoding="utf-8", + ) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + _stub_channel_registry(monkeypatch, plugin) + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + + payload = disable_optional_feature( + "managedmulti", + config_path=config_path, + instance_id="product", + ) + + saved = json.loads(config_path.read_text(encoding="utf-8")) + assert [item["enabled"] for item in saved["channels"]["managedmulti"]["instances"]] == [ + True, + False, + ] + feature = payload["features"][0] + assert feature["enabled"] is True + assert [item["enabled"] for item in feature["instances"]] == [True, False] + + +def test_feishu_enable_rejects_duplicate_instance_ids_without_writing(tmp_path): + from nanobot.channels.registry import load_channel_plugin + from nanobot.optional_features import OptionalFeatureError, set_channel_config_enabled + + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({ + "channels": { + "feishu": { + "instances": [ + {"id": "default", "enabled": False, "appId": "A"}, + {"id": "default", "enabled": True, "appId": "B"}, + ] + } + } + }), + encoding="utf-8", + ) + before = config_path.read_text(encoding="utf-8") + + with pytest.raises(OptionalFeatureError, match="duplicate Feishu instance id 'default'"): + set_channel_config_enabled(config_path, "feishu", load_channel_plugin("feishu"), True) + + assert config_path.read_text(encoding="utf-8") == before + + def test_optional_features_payload_counts_enabled_channel_with_missing_dependency( monkeypatch, ): from nanobot.optional_features import optional_features_payload config = Config.model_validate({"channels": {"matrix": {"enabled": True}}}) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "matrix") monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", lambda: {"matrix": ["matrix-nio>=0.25.2"]}, @@ -1110,6 +1807,82 @@ def test_optional_features_payload_counts_enabled_channel_with_missing_dependenc assert payload["enabled_count"] == 1 +def test_live_runtime_status_overrides_enabled_configuration_for_webui(): + from nanobot.optional_features import with_channel_runtime_status + + payload = { + "features": [{ + "name": "feishu", + "type": "channel", + "enabled": True, + "ready": True, + "status": "enabled", + "instances": [{ + "id": "default", + "enabled": True, + "configured": True, + }], + }], + "enabled_count": 1, + } + runtime_status = { + "feishu": { + "owner": "feishu", + "instance_id": "default", + "state": "failed", + "running": False, + "error": "Channel failed to start. Check gateway logs.", + } + } + + decorated = with_channel_runtime_status(payload, runtime_status) + + feature = decorated["features"][0] + assert feature["enabled"] is True # desired config remains visible to actions + assert feature["running"] is False + assert feature["ready"] is False + assert feature["runtime_status"] == "failed" + assert feature["runtime_error"] == "Channel failed to start. Check gateway logs." + assert feature["instances"][0]["runtime_status"] == "failed" + assert decorated["enabled_count"] == 0 + + +def test_package_manifest_metadata_drives_optional_feature_payload(monkeypatch): + from nanobot.optional_features import optional_features_payload + + plugin = ChannelPlugin( + name="demo", + display_name="Demo Chat", + runtime="demo.runtime:DemoChannel", + dependencies=("demo-sdk>=1",), + default_enabled=True, + capabilities=frozenset({"custom_ui"}), + webui="webui/entry.tsx", + ) + config = Config.model_validate({"channels": {"demo": {"enabled": False}}}) + checked_extras: list[tuple[str, list[str] | None]] = [] + + _stub_channel_registry(monkeypatch, plugin) + monkeypatch.setattr( + "nanobot.optional_features.optional_dependency_groups", + lambda: {}, + ) + + def record_extra(extra: str, deps: list[str] | None) -> bool: + checked_extras.append((extra, deps)) + return True + + monkeypatch.setattr("nanobot.optional_features.extra_installed", record_extra) + + payload = optional_features_payload(config=config) + + demo = next(feature for feature in payload["features"] if feature["name"] == "demo") + assert checked_extras == [("demo", ["demo-sdk>=1"])] + assert demo["display_name"] == "Demo Chat" + assert demo["capabilities"] == ["custom_ui"] + assert demo["webui"] == "webui/entry.tsx" + + def test_optional_features_payload_reflects_saved_channel_config(monkeypatch): from nanobot.optional_features import optional_features_payload @@ -1123,8 +1896,7 @@ def test_optional_features_payload_reflects_saved_channel_config(monkeypatch): } } }) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "discord") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = optional_features_payload(config=config) @@ -1149,8 +1921,7 @@ def test_optional_features_payload_marks_enabled_channel_missing_credentials(mon from nanobot.optional_features import optional_features_payload config = Config.model_validate({"channels": {"discord": {"enabled": True}}}) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "discord") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = optional_features_payload(config=config) @@ -1179,8 +1950,7 @@ def test_optional_features_payload_detects_saved_weixin_login_state(tmp_path, mo } } }) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "weixin") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = optional_features_payload(config=config) @@ -1203,8 +1973,7 @@ def test_optional_features_payload_detects_legacy_default_weixin_state(tmp_path, json.dumps({"token": "legacy-weixin-token"}), encoding="utf-8", ) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "weixin") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = optional_features_payload(config=Config()) @@ -1232,8 +2001,7 @@ def test_optional_features_payload_requires_matrix_device_id_for_token_login( } } }) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "matrix") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = optional_features_payload(config=config) @@ -1253,8 +2021,13 @@ def test_optional_features_payload_marks_disabled_feishu_as_configured(monkeypat } } }) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + + plugin = load_channel_package("feishu") + assert plugin is not None + _stub_channel_registry( + monkeypatch, + replace(plugin, runtime="missing.feishu.runtime:FeishuChannel"), + ) monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = optional_features_payload(config=config) @@ -1264,10 +2037,12 @@ def test_optional_features_payload_marks_disabled_feishu_as_configured(monkeypat assert feishu["enabled"] is False assert feishu["configured"] is True assert feishu["ready"] is False + assert feishu["setup"]["fields"][0]["key"] == "channels.feishu.appId" assert payload["enabled_count"] == 0 def test_optional_features_payload_lists_feishu_instances(monkeypatch): + from nanobot.channels.plugin import load_channel_package from nanobot.optional_features import optional_features_payload config = Config.model_validate({ @@ -1294,8 +2069,12 @@ def test_optional_features_payload_lists_feishu_instances(monkeypatch): } } }) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + plugin = load_channel_package("feishu") + assert plugin is not None + _stub_channel_registry( + monkeypatch, + replace(plugin, runtime="missing.feishu.runtime:FeishuChannel"), + ) monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) payload = optional_features_payload(config=config) @@ -1311,30 +2090,48 @@ def test_optional_features_payload_lists_feishu_instances(monkeypatch): "name": "nanobot", "display_name": "Voraflare Bot", "avatar_url": "https://example.com/bot.png", - "domain": "feishu", "enabled": True, "configured": True, - "app_id": "cli_default", - "group_policy": "mention", - "allow_from": [], + "config_values": { + "channels.feishu.appId": "cli_default", + "channels.feishu.domain": "feishu", + "channels.feishu.groupPolicy": "mention", + "channels.feishu.topicIsolation": "true", + }, + "configured_fields": [ + "channels.feishu.appId", + "channels.feishu.appSecret", + "channels.feishu.domain", + "channels.feishu.groupPolicy", + "channels.feishu.topicIsolation", + ], }, { "id": "product", "name": "Product bot", "display_name": "Product bot", "avatar_url": "", - "domain": "feishu", "enabled": False, "configured": True, - "app_id": "cli_product", - "group_policy": "mention", - "allow_from": [], + "config_values": { + "channels.feishu.appId": "cli_product", + "channels.feishu.domain": "feishu", + "channels.feishu.groupPolicy": "mention", + "channels.feishu.topicIsolation": "true", + }, + "configured_fields": [ + "channels.feishu.appId", + "channels.feishu.appSecret", + "channels.feishu.domain", + "channels.feishu.groupPolicy", + "channels.feishu.topicIsolation", + ], }, ] -def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, tmp_path): - from nanobot.channels import feishu as feishu_module +def test_optional_features_payload_does_not_refresh_saved_feishu_identity(monkeypatch, tmp_path): + from nanobot.channels.feishu import runtime as feishu_module from nanobot.config import loader from nanobot.optional_features import optional_features_payload @@ -1356,21 +2153,63 @@ def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, config_path, ) monkeypatch.setattr(loader, "_current_config_path", config_path) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "feishu") + monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) + monkeypatch.setattr( + feishu_module, + "fetch_feishu_app_identity", + lambda *_args: pytest.fail("feature discovery must not call Feishu"), + ) + before = config_path.read_text(encoding="utf-8") + + payload = optional_features_payload() + + instance = payload["features"][0]["instances"][0] + assert instance["display_name"] == "nanobot" + assert instance["avatar_url"] == "" + assert config_path.read_text(encoding="utf-8") == before + + +def test_enable_optional_feature_refreshes_feishu_identity( + monkeypatch, + tmp_path, +): + from nanobot.channels.feishu import runtime as feishu_module + from nanobot.config import loader + from nanobot.optional_features import enable_optional_feature + + config_path = tmp_path / "config.json" + save_config( + Config.model_validate({ + "channels": { + "feishu": { + "instances": [{ + "id": "default", + "name": "nanobot", + "enabled": True, + "appId": "cli_default", + "appSecret": "secret", + }] + } + } + }), + config_path, + ) + monkeypatch.setattr(loader, "_current_config_path", config_path) + _stub_channel_packages(monkeypatch, "feishu") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True) monkeypatch.setattr( feishu_module, "fetch_feishu_app_identity", - lambda app_id, app_secret, domain: { + lambda *_args: { "displayName": "Xubin Ren的智能助手", "avatarUrl": "https://example.com/assistant.png", "identityFetchedAt": "2026-07-06T00:00:00Z", }, ) - payload = optional_features_payload() + payload = enable_optional_feature("feishu", config_path=config_path) instance = payload["features"][0]["instances"][0] assert instance["display_name"] == "Xubin Ren的智能助手" @@ -1383,54 +2222,8 @@ def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z" -def test_optional_features_payload_records_feishu_identity_attempt_on_empty_result( - monkeypatch, - tmp_path, -): - from nanobot.channels import feishu as feishu_module - from nanobot.config import loader - from nanobot.optional_features import optional_features_payload - - config_path = tmp_path / "config.json" - save_config( - Config.model_validate({ - "channels": { - "feishu": { - "instances": [{ - "id": "default", - "name": "nanobot", - "enabled": True, - "appId": "cli_default", - "appSecret": "secret", - }] - } - } - }), - config_path, - ) - monkeypatch.setattr(loader, "_current_config_path", config_path) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) - monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) - monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True) - monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *args: {}) - monkeypatch.setattr(feishu_module, "_identity_timestamp", lambda: "2026-07-06T00:00:00Z") - - payload = optional_features_payload() - - instance = payload["features"][0]["instances"][0] - assert instance["display_name"] == "nanobot" - assert instance["avatar_url"] == "" - - data = json.loads(config_path.read_text(encoding="utf-8")) - saved = data["channels"]["feishu"]["instances"][0] - assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z" - assert "displayName" not in saved - assert "avatarUrl" not in saved - - def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypatch, tmp_path): - from nanobot.channels import feishu as feishu_module + from nanobot.channels.feishu import runtime as feishu_module from nanobot.config import loader from nanobot.optional_features import optional_features_payload @@ -1449,28 +2242,24 @@ def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypat config_path, ) monkeypatch.setattr(loader, "_current_config_path", config_path) - monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"]) - monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {}) + _stub_channel_packages(monkeypatch, "feishu") monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {}) - monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True) monkeypatch.setattr( feishu_module, "fetch_feishu_app_identity", - lambda *_args: { - "displayName": "Legacy assistant", - "avatarUrl": "https://example.com/legacy.png", - "identityFetchedAt": "2026-07-06T00:00:00Z", - }, + lambda *_args: pytest.fail("feature discovery must not call Feishu"), ) + before = config_path.read_text(encoding="utf-8") payload = optional_features_payload() - assert payload["features"][0]["instances"][0]["display_name"] == "Legacy assistant" + assert payload["features"][0]["instances"][0]["display_name"] == "nanobot" + assert config_path.read_text(encoding="utf-8") == before saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"] assert saved["appId"] == "cli_legacy" assert saved["appSecret"] == "legacy-secret" - assert saved["displayName"] == "Legacy assistant" - assert saved["avatarUrl"] == "https://example.com/legacy.png" + assert "displayName" not in saved + assert "avatarUrl" not in saved assert "instances" not in saved @@ -1485,11 +2274,11 @@ def test_enable_bootstraps_pip_with_ensurepip(monkeypatch): return subprocess.CompletedProcess(argv, 1, stdout="", stderr="No module named pip") return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") - assert optional_features.install_extra("weixin", None, runner=_run).ok is True + assert optional_features.install_extra("bedrock", None, runner=_run).ok is True assert calls == [ - [sys.executable, "-m", "pip", "install", "nanobot-ai[weixin]"], + [sys.executable, "-m", "pip", "install", "nanobot-ai[bedrock]"], [sys.executable, "-m", "ensurepip", "--upgrade"], - [sys.executable, "-m", "pip", "install", "nanobot-ai[weixin]"], + [sys.executable, "-m", "pip", "install", "nanobot-ai[bedrock]"], ] @@ -1532,6 +2321,7 @@ def test_run_install_command_returns_failure_on_timeout(monkeypatch): def test_optional_dependency_metadata_for_enable(): from nanobot import optional_features + from nanobot.channels.plugin import load_channel_package data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) deps = data["project"]["optional-dependencies"] @@ -1560,7 +2350,6 @@ def test_optional_dependency_metadata_for_enable(): "python-pptx>=1.0.0,<2.0.0", ): assert dependency in required - assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"] assert deps["documents"] == [ "defusedxml>=0.7.1,<1.0.0", "pypdf>=5.0.0,<6.0.0", @@ -1569,28 +2358,69 @@ def test_optional_dependency_metadata_for_enable(): "python-pptx>=1.0.0,<2.0.0", ] assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"] - assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"] assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"] - assert deps["mochat"] == [ - "python-socketio>=5.16.0,<6.0.0", - "msgpack>=1.1.0,<2.0.0", - ] - assert deps["napcat"] == ["aiohttp>=3.9.0,<4.0.0"] - assert deps["qq"] == ["aiohttp>=3.9.0,<4.0.0", "qq-botpy>=1.2.0,<2.0.0"] - assert deps["slack"] == [ - "aiohttp>=3.9.0,<4.0.0", - "slack-sdk>=3.39.0,<4.0.0", - "slackify-markdown>=0.2.0,<1.0.0", - ] + channel_names = { + "dingtalk", + "discord", + "feishu", + "matrix", + "mochat", + "msteams", + "napcat", + "qq", + "slack", + "telegram", + "wecom", + "weixin", + "whatsapp", + } + assert channel_names.isdisjoint(deps) + expected_channel_dependencies = { + "dingtalk": ("dingtalk-stream>=0.24.0,<1.0.0",), + "discord": ("discord.py>=2.5.2,<3.0.0",), + "feishu": ("lark-oapi>=1.5.0,<2.0.0",), + "matrix": ( + "matrix-nio[e2e]>=0.25.2; sys_platform != 'win32'", + "matrix-nio>=0.25.2; sys_platform == 'win32'", + "aiohttp>=3.9.0,<4.0.0", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", + ), + "mochat": ( + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + ), + "msteams": ("PyJWT>=2.0,<3.0", "cryptography>=41.0"), + "napcat": ("aiohttp>=3.9.0,<4.0.0",), + "qq": ( + "aiohttp>=3.9.0,<4.0.0", + "qq-botpy>=1.2.0,<2.0.0", + ), + "slack": ( + "aiohttp>=3.9.0,<4.0.0", + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + ), + "telegram": ( + "python-telegram-bot[socks,webhooks]>=22.6,<23.0", + "socksio>=1.0.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'", + ), + "wecom": ("wecom-aibot-sdk-python>=0.1.5",), + "weixin": ("qrcode[pil]>=8.0", "pycryptodome>=3.20.0"), + "whatsapp": ( + "neonize>=0.3.18.post0,<0.4.0", + "segno>=1.6.1,<2.0.0", + ), + } + for name, expected in expected_channel_dependencies.items(): + plugin = load_channel_package(name) + assert plugin is not None + assert plugin.dependencies == expected visible = optional_features.optional_dependency_groups() assert "documents" not in visible assert "pdf" not in visible - assert any(dep.startswith("python-telegram-bot") for dep in deps["telegram"]) - assert any( - dep.startswith("matrix-nio>=0.25.2") and "sys_platform == 'win32'" in dep - for dep in deps["matrix"] - ) def test_optional_dependency_groups_falls_back_to_package_metadata(monkeypatch): @@ -1684,7 +2514,7 @@ def test_requirement_installed_validates_requested_extras(monkeypatch): @pytest.mark.asyncio -async def test_manager_skips_disabled_plugin(): +async def test_manager_skips_disabled_channel_package(monkeypatch): fake_config = SimpleNamespace( channels=ChannelsConfig.model_validate({ "fakeplugin": {"enabled": False}, @@ -1692,34 +2522,33 @@ async def test_manager_skips_disabled_plugin(): providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), ) - ep = _make_entry_point("fakeplugin", _FakePlugin) - with patch(_EP_TARGET, return_value=[ep]): - mgr = ChannelManager.__new__(ChannelManager) - mgr.config = fake_config - mgr.bus = MessageBus() - mgr.channels = {} - mgr._dispatch_task = None - mgr._init_channels() + _stub_channel_registry(monkeypatch, _channel_plugin(_FakePlugin)) + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.bus = MessageBus() + mgr.channels = {} + mgr._dispatch_task = None + mgr._init_channels() assert "fakeplugin" not in mgr.channels # --------------------------------------------------------------------------- -# Built-in channel default_config() and dict->Pydantic conversion +# Channel default_config() and dict-to-Pydantic conversion # --------------------------------------------------------------------------- -def test_builtin_channel_default_config(): - """Built-in channels expose default_config() returning a dict with 'enabled': False.""" - from nanobot.channels.dingtalk import DingTalkChannel +def test_channel_default_config(): + """Channels expose default_config() returning a dict with 'enabled': False.""" + from nanobot.channels.dingtalk.runtime import DingTalkChannel cfg = DingTalkChannel.default_config() assert isinstance(cfg, dict) assert cfg["enabled"] is False assert "clientId" in cfg -def test_builtin_channel_init_from_dict(): - """Built-in channels accept a raw dict and convert to Pydantic internally.""" - from nanobot.channels.dingtalk import DingTalkChannel +def test_channel_init_from_dict(): + """Channels accept a raw dict and convert to Pydantic internally.""" + from nanobot.channels.dingtalk.runtime import DingTalkChannel bus = MessageBus() ch = DingTalkChannel({"enabled": False, "clientId": "test-id", "allowFrom": ["*"]}, bus) assert ch.config.client_id == "test-id" @@ -1899,7 +2728,7 @@ async def test_send_with_retry_no_retry_when_max_is_zero(): @pytest.mark.asyncio async def test_send_with_retry_calls_send_delta(): """_send_with_retry should call send_delta for stream delta events.""" - send_delta_called = False + calls: list[tuple[str, str, str | None, bool, bool]] = [] class _StreamingChannel(BaseChannel): name = "streaming" @@ -1924,8 +2753,7 @@ async def test_send_with_retry_calls_send_delta(): stream_end: bool = False, resuming: bool = False, ) -> None: - nonlocal send_delta_called - send_delta_called = True + calls.append((chat_id, delta, stream_id, stream_end, resuming)) fake_config = SimpleNamespace( channels=ChannelsConfig(send_max_retries=3), @@ -1941,138 +2769,19 @@ async def test_send_with_retry_calls_send_delta(): msg = outbound_message_for_event( channel="streaming", chat_id="123", - event=StreamDeltaEvent(content="test delta"), + event=StreamDeltaEvent(content="test delta", stream_id="s1"), ) await mgr._send_with_retry(mgr.channels["streaming"], msg) - - assert send_delta_called is True - - -@pytest.mark.asyncio -async def test_send_with_retry_supports_legacy_stream_delta_signature(): - """External plugins with the old send_delta signature should keep working.""" - calls: list[tuple[str, str, dict]] = [] - - class _LegacyStreamingChannel(BaseChannel): - name = "legacy_streaming" - display_name = "Legacy Streaming" - - async def start(self) -> None: - pass - - async def stop(self) -> None: - pass - - async def send(self, msg: OutboundMessage) -> None: - pass - - async def send_delta( - self, - chat_id: str, - delta: str, - metadata: dict | None = None, - ) -> None: - calls.append((chat_id, delta, dict(metadata or {}))) - - fake_config = SimpleNamespace( - channels=ChannelsConfig(send_max_retries=3), - providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), - ) - mgr = ChannelManager.__new__(ChannelManager) - mgr.config = fake_config - mgr.bus = MessageBus() - mgr.channels = {"legacy_streaming": _LegacyStreamingChannel(fake_config, mgr.bus)} - mgr._dispatch_task = None - - await mgr._send_with_retry( - mgr.channels["legacy_streaming"], - outbound_message_for_event( - channel="legacy_streaming", - chat_id="123", - event=StreamDeltaEvent(content="hello", stream_id="s1"), - ), - ) - await mgr._send_with_retry( - mgr.channels["legacy_streaming"], - outbound_message_for_event( - channel="legacy_streaming", - chat_id="123", - event=StreamEndEvent(content="", stream_id="s1", resuming=True), - ), + end = outbound_message_for_event( + channel="streaming", + chat_id="123", + event=StreamEndEvent(content="", stream_id="s1", resuming=True), ) + await mgr._send_with_retry(mgr.channels["streaming"], end) assert calls == [ - ("123", "hello", {"_stream_id": "s1", "_stream_delta": True}), - ("123", "", {"_stream_id": "s1", "_stream_end": True}), - ] - - -@pytest.mark.asyncio -async def test_send_with_retry_supports_legacy_reasoning_signature(): - """External plugins with the old reasoning hook signature should keep working.""" - deltas: list[tuple[str, str, dict]] = [] - ends: list[tuple[str, dict]] = [] - - class _LegacyReasoningChannel(BaseChannel): - name = "legacy_reasoning" - display_name = "Legacy Reasoning" - - async def start(self) -> None: - pass - - async def stop(self) -> None: - pass - - async def send(self, msg: OutboundMessage) -> None: - pass - - async def send_reasoning_delta( - self, - chat_id: str, - delta: str, - metadata: dict | None = None, - ) -> None: - deltas.append((chat_id, delta, dict(metadata or {}))) - - async def send_reasoning_end( - self, - chat_id: str, - metadata: dict | None = None, - ) -> None: - ends.append((chat_id, dict(metadata or {}))) - - fake_config = SimpleNamespace( - channels=ChannelsConfig(send_max_retries=3), - providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), - ) - mgr = ChannelManager.__new__(ChannelManager) - mgr.config = fake_config - mgr.bus = MessageBus() - mgr.channels = {"legacy_reasoning": _LegacyReasoningChannel(fake_config, mgr.bus)} - mgr._dispatch_task = None - - await mgr._send_with_retry( - mgr.channels["legacy_reasoning"], - outbound_message_for_event( - channel="legacy_reasoning", - chat_id="123", - event=ProgressEvent(content="thinking", reasoning_delta=True, stream_id="r1"), - ), - ) - await mgr._send_with_retry( - mgr.channels["legacy_reasoning"], - outbound_message_for_event( - channel="legacy_reasoning", - chat_id="123", - event=ProgressEvent(reasoning_end=True, stream_id="r1"), - ), - ) - - assert deltas == [ - ("123", "thinking", {"_reasoning_delta": True, "_stream_id": "r1"}), - ] - assert ends == [ - ("123", {"_reasoning_end": True, "_stream_id": "r1"}), + ("123", "test delta", "s1", False, False), + ("123", "", "s1", True, True), ] diff --git a/tests/channels/test_channel_setup.py b/tests/channels/test_channel_setup.py index 0f7cf9dd..5d2e58ae 100644 --- a/tests/channels/test_channel_setup.py +++ b/tests/channels/test_channel_setup.py @@ -1,4 +1,37 @@ +import ast +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +import nanobot.channels._setup as channel_setup_module +import nanobot.channels.registry as registry_module from nanobot.channels._setup import channel_setup_spec +from nanobot.channels.plugin import ChannelPlugin, load_channel_package +from nanobot.channels.registry import channel_default_enabled, discover_plugins + +EXPECTED_CHANNELS = { + "dingtalk", + "discord", + "email", + "feishu", + "matrix", + "mattermost", + "mochat", + "msteams", + "napcat", + "qq", + "signal", + "slack", + "telegram", + "websocket", + "wecom", + "weixin", + "whatsapp", +} def test_channel_setup_spec_derives_route_and_secret_metadata() -> None: @@ -12,6 +45,13 @@ def test_channel_setup_spec_derives_route_and_secret_metadata() -> None: "groupPolicy": ("enum", {"mention", "open", "allowlist"}), } assert slack.simple_required_fields == ("appToken", "botToken") + assert slack.fields["groupPolicy"].default == "mention" + group_policy = next( + field + for field in slack.to_public_dict("slack")["fields"] + if field["field"] == "groupPolicy" + ) + assert group_policy["default_value"] == "mention" def test_matrix_setup_requires_one_complete_login_method() -> None: @@ -52,3 +92,192 @@ def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None: "enum", {"mention", "open"}, ) + + +def test_every_channel_is_a_self_contained_package() -> None: + channel_dir = Path(channel_setup_module.__file__).parent + package_names = {path.parent.name for path in channel_dir.glob("*/manifest.py")} + + assert not hasattr(channel_setup_module, "CHANNEL_SETUP_SPECS") + assert package_names == EXPECTED_CHANNELS + assert set(discover_plugins()) == EXPECTED_CHANNELS + for name in EXPECTED_CHANNELS: + package_dir = channel_dir / name + assert (package_dir / "__init__.py").is_file() + assert (package_dir / "manifest.py").is_file() + assert (package_dir / "runtime.py").is_file() + assert not (channel_dir / f"{name}.py").exists() + + plugin = load_channel_package(name) + assert plugin is not None + assert plugin.name == name + assert plugin.runtime.startswith(f"nanobot.channels.{name}.runtime:") + assert plugin.setup is channel_setup_spec(name) + if plugin.webui is not None: + assert (package_dir / plugin.webui).is_file() + + +def test_channel_locales_cover_authoritative_setup_contracts() -> None: + channel_dir = Path(channel_setup_module.__file__).parent + for name in EXPECTED_CHANNELS: + plugin = load_channel_package(name) + assert plugin is not None + if plugin.webui is None or plugin.setup is None: + continue + english = json.loads( + (channel_dir / name / "webui" / "locales" / "en.json").read_text(encoding="utf-8") + ) + setup_messages = english["setup"] + field_messages = setup_messages.get("fields", {}) + for field_name, field in plugin.setup.fields.items(): + if not field.writable: + continue + message_key = re.sub(r"[^A-Za-z0-9_-]+", "_", field_name) + assert message_key in field_messages, f"{name} field {field_name} has no locale copy" + if plugin.setup.official_url: + assert setup_messages.get("officialLabel"), f"{name} has no localized official label" + + +def test_channel_manifests_only_import_contract_modules() -> None: + channel_dir = Path(channel_setup_module.__file__).parent + allowed_imports = { + "nanobot.channels._manifest", + "nanobot.channels.contracts", + "nanobot.channels.plugin", + } + + for name in EXPECTED_CHANNELS: + manifest_path = channel_dir / name / "manifest.py" + tree = ast.parse(manifest_path.read_text(encoding="utf-8")) + imports: set[str] = set() + for node in tree.body: + if isinstance(node, ast.Import): + imports.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imports.add(node.module) + allowed_channel_imports = { + module + for module in imports + if module.startswith(f"nanobot.channels.{name}.") + and not module.endswith(".runtime") + } + unexpected = imports - allowed_imports - allowed_channel_imports + assert not unexpected, f"{name} imports runtime dependencies: {unexpected}" + + +def test_runtime_classes_do_not_declare_persisted_management_hooks() -> None: + channel_dir = Path(channel_setup_module.__file__).parent + management_hooks = { + "feature_instances", + "instance_specs", + "runtime_name", + "supports_multiple_instances", + "update_instance_config", + } + for name in EXPECTED_CHANNELS: + tree = ast.parse((channel_dir / name / "runtime.py").read_text(encoding="utf-8")) + declared = { + item.name + for node in tree.body + if isinstance(node, ast.ClassDef) + for item in node.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + assert declared.isdisjoint(management_hooks), f"{name} runtime owns {declared & management_hooks}" + + +def test_feishu_package_manifest_owns_runtime_and_webui_metadata() -> None: + plugin = load_channel_package("feishu") + + assert plugin is not None + assert plugin.runtime == "nanobot.channels.feishu.runtime:FeishuChannel" + assert plugin.dependencies == ("lark-oapi>=1.5.0,<2.0.0",) + assert plugin.connector == "nanobot.channels.feishu.connect:FeishuConnectStore" + assert plugin.management.multi_instance is True + assert plugin.webui == "webui/index.tsx" + + +def test_weixin_package_manifest_owns_runtime_and_webui_metadata() -> None: + plugin = load_channel_package("weixin") + + assert plugin is not None + assert plugin.runtime == "nanobot.channels.weixin.runtime:WeixinChannel" + assert plugin.dependencies == ("qrcode[pil]>=8.0", "pycryptodome>=3.20.0") + assert plugin.connector == "nanobot.channels.weixin.connect:WeixinConnectStore" + assert plugin.webui == "webui/index.tsx" + + +def test_package_manifests_do_not_import_runtimes() -> None: + code = f""" +import sys +from nanobot.channels.plugin import load_channel_package + +for name in {sorted(EXPECTED_CHANNELS)!r}: + plugin = load_channel_package(name) + assert plugin is not None + assert f"nanobot.channels.{{name}}.runtime" not in sys.modules +""" + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_channel_plugin_normalizes_webui_entry() -> None: + plugin = ChannelPlugin( + name="demo", + display_name="Demo", + runtime="example.demo.runtime:DemoChannel", + webui="webui\\index.tsx", + ) + + assert plugin.webui == "webui/index.tsx" + + +def test_channel_plugin_name_must_match_package_identifier() -> None: + with pytest.raises(ValueError, match="letters, digits, or underscores"): + ChannelPlugin( + name="google-chat", + display_name="Google Chat", + runtime="example.google_chat.runtime:GoogleChatChannel", + ) + + +def test_channel_plugin_rejects_invalid_runtime_import_path() -> None: + with pytest.raises(ValueError, match="absolute import path"): + ChannelPlugin( + name="demo", + display_name="Demo", + runtime="../runtime:DemoChannel", + ) + + +def test_channel_default_enabled_uses_package_manifest(monkeypatch) -> None: + plugin = ChannelPlugin( + name="demo", + display_name="Demo", + runtime="example.demo.runtime:DemoChannel", + default_enabled=True, + ) + monkeypatch.setattr( + registry_module, + "load_channel_plugin", + lambda name: plugin if name == "demo" else (_ for _ in ()).throw(ImportError()), + ) + + assert channel_default_enabled("demo") is True + assert channel_default_enabled("missing") is False + + +def test_websocket_manifest_declares_the_only_default_enabled_channel() -> None: + enabled = { + name + for name in EXPECTED_CHANNELS + if (plugin := load_channel_package(name)) is not None and plugin.default_enabled + } + + assert enabled == {"websocket"} diff --git a/tests/channels/test_channel_validation.py b/tests/channels/test_channel_validation.py new file mode 100644 index 00000000..7afff92d --- /dev/null +++ b/tests/channels/test_channel_validation.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import pytest + +from nanobot.channels import validation + + +def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None: + connected: list[tuple[str, int]] = [] + + class FakeSocket: + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + monkeypatch.setattr( + validation, + "resolve_url_target", + lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)), + ) + monkeypatch.setattr( + validation.socket, + "create_connection", + lambda target, **_kwargs: connected.append(target) or FakeSocket(), + ) + + validation.probe_tcp("mail.example.com", 2525) + + assert connected == [("203.0.113.10", 2525)] diff --git a/tests/channels/test_feishu_lazy_import.py b/tests/channels/test_feishu_lazy_import.py deleted file mode 100644 index 05915c27..00000000 --- a/tests/channels/test_feishu_lazy_import.py +++ /dev/null @@ -1,59 +0,0 @@ -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 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 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" diff --git a/tests/channels/test_feishu_ws.py b/tests/channels/test_feishu_ws.py deleted file mode 100644 index 54992f51..00000000 --- a/tests/channels/test_feishu_ws.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import asyncio -import threading - -from nanobot.channels._feishu_ws import FeishuWsRunner - - -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() diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 09d02129..a394f0b5 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -480,18 +480,25 @@ def test_config_dump_excludes_oauth_provider_blocks(): def test_plugins_list_uses_explicit_config(monkeypatch, tmp_path: Path): + from nanobot.channels.plugin import ChannelPlugin + config_path = tmp_path / "config.json" config_path.write_text( json.dumps({"channels": {"example": {"enabled": True}}}), encoding="utf-8", ) - monkeypatch.setattr( - "nanobot.channels.registry.discover_channel_names", - lambda: ["example"], + plugin = ChannelPlugin( + name="example", + display_name="Example", + runtime="example.runtime:ExampleChannel", ) monkeypatch.setattr( "nanobot.channels.registry.discover_plugins", - lambda: {}, + lambda enabled_names=None: ( + {"example": plugin} + if enabled_names is None or "example" in enabled_names + else {} + ), ) monkeypatch.setattr( "nanobot.optional_features.optional_dependency_groups", diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index 96221d09..10ba967a 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -131,7 +131,7 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None: def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None: - from types import SimpleNamespace + from nanobot.channels.plugin import load_channel_package config_path = tmp_path / "config.json" workspace = tmp_path / "workspace" @@ -153,19 +153,13 @@ def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path) monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace) + monkeypatch.setattr( + "nanobot.channels.registry.discover_plugins", + lambda: {"qq": load_channel_package("qq")}, + ) monkeypatch.setattr( "nanobot.channels.registry.discover_all", - lambda: { - "qq": SimpleNamespace( - default_config=lambda: { - "enabled": False, - "appId": "", - "secret": "", - "allowFrom": [], - "msgFormat": "plain", - } - ) - }, + lambda: pytest.fail("onboarding must not import channel runtimes"), ) from typer.testing import CliRunner diff --git a/tests/tools/test_exec_session_tools.py b/tests/tools/test_exec_session_tools.py index 3622058a..dfdddf1f 100644 --- a/tests/tools/test_exec_session_tools.py +++ b/tests/tools/test_exec_session_tools.py @@ -24,7 +24,7 @@ def _python_command(code: str) -> str: def _waiting_shell_command(initial: str, *, delayed: str | None = None) -> str: - """Print deterministic output, then wait in the shell itself for stdin. + """Print deterministic output, optionally gated by stdin, then keep waiting. Long-lived Python children keep inherited pipes open after their parent shell is terminated on Windows. These tests exercise exec-session control, @@ -36,13 +36,13 @@ def _waiting_shell_command(initial: str, *, delayed: str | None = None) -> str: parts = [f"Write-Output {quote(initial)}"] if delayed is not None: - parts.extend(("Start-Sleep -Milliseconds 100", f"Write-Output {quote(delayed)}")) + parts.extend(("$null = [Console]::In.ReadLine()", f"Write-Output {quote(delayed)}")) parts.append("$null = [Console]::In.ReadLine()") return "; ".join(parts) parts = [f"printf '%s\\n' {shlex.quote(initial)}"] if delayed is not None: - parts.extend(("sleep 0.1", f"printf '%s\\n' {shlex.quote(delayed)}")) + parts.extend(("IFS= read -r _", f"printf '%s\\n' {shlex.quote(delayed)}")) parts.append("IFS= read -r _") return "; ".join(parts) @@ -309,6 +309,7 @@ def test_write_stdin_can_wait_for_expected_output(tmp_path): sid = _session_id(initial) waited = await stdin_tool.execute( session_id=sid, + chars="\n", wait_for="ready", wait_timeout_ms=1000, yield_time_ms=0, diff --git a/tests/webui/test_build.py b/tests/webui/test_build.py index fda0b5c3..5b70d17b 100644 --- a/tests/webui/test_build.py +++ b/tests/webui/test_build.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import tomllib from pathlib import Path from nanobot.webui.build import ensure_webui_bundle, inspect_webui_bundle @@ -56,6 +57,28 @@ def test_inspect_webui_bundle_detects_source_newer_than_dist(tmp_path: Path) -> assert status.newest_source == source / "src" / "App.tsx" +def test_inspect_webui_bundle_detects_channel_owned_ui_source(tmp_path: Path) -> None: + source = tmp_path / "webui" + dist = tmp_path / "nanobot" / "web" / "dist" + channel_ui = tmp_path / "nanobot" / "channels" / "example" / "webui" / "index.tsx" + _touch(source / "package.json", mtime_ns=10) + _touch(dist / "index.html", mtime_ns=20) + _touch(channel_ui, mtime_ns=30) + + status = inspect_webui_bundle(source_dir=source, dist_dir=dist) + + assert status.needs_build is True + assert status.reason == "source_newer" + assert status.newest_source == channel_ui + + +def test_channel_owned_ui_sources_are_included_in_distributions() -> None: + project_root = Path(__file__).resolve().parents[2] + pyproject = tomllib.loads((project_root / "pyproject.toml").read_text(encoding="utf-8")) + + assert "nanobot/channels/*/webui/**/*" in pyproject["tool"]["hatch"]["build"]["include"] + + def test_inspect_webui_bundle_accepts_fresh_dist(tmp_path: Path) -> None: source = tmp_path / "webui" dist = tmp_path / "nanobot" / "web" / "dist" diff --git a/tests/webui/test_channel_validation.py b/tests/webui/test_channel_validation.py deleted file mode 100644 index 09773c44..00000000 --- a/tests/webui/test_channel_validation.py +++ /dev/null @@ -1,231 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest - -from nanobot.config.loader import load_config, save_config -from nanobot.config.schema import Config -from nanobot.webui import channel_validation -from nanobot.webui.channel_validation import validate_channel_config - - -def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: - config_path = tmp_path / "config.json" - config = Config.model_validate( - { - "channels": { - "slack": { - "appToken": "xapp-old", - "botToken": "xoxb-old", - "groupPolicy": "mention", - } - } - } - ) - save_config(config, config_path) - monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - monkeypatch.setattr(channel_validation, "_http_post", lambda *_args, **_kwargs: {"ok": True}) - - payload = validate_channel_config( - "slack", - { - "channels.slack.appToken": "", - "channels.slack.botToken": "", - }, - ) - - assert payload["status"] == "connected" - saved = load_config(config_path) - assert saved.channels.slack["appToken"] == "xapp-old" - assert saved.channels.slack["botToken"] == "xoxb-old" - - -def test_validate_telegram_bad_token_is_invalid(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) - - payload = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"}) - - assert payload["status"] == "invalid" - assert payload["can_enable"] is False - assert payload["missing_fields"] == [] - - -def test_validate_telegram_does_not_expose_saved_token_in_http_errors( - tmp_path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - token = "123456:abcdefghijklmnopqrstuvwxyz" - config_path = tmp_path / "config.json" - save_config( - Config.model_validate({"channels": {"telegram": {"token": token}}}), - config_path, - ) - monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - - def raise_http_error(url: str, **_kwargs) -> dict: - request = httpx.Request("GET", url) - response = httpx.Response(401, request=request) - raise httpx.HTTPStatusError("unauthorized", request=request, response=response) - - monkeypatch.setattr(channel_validation, "_http_get", raise_http_error) - - payload = validate_channel_config("telegram", {"channels.telegram.token": ""}) - - assert token not in str(payload) - assert any("HTTP 401" in check.get("message", "") for check in payload["checks"]) - - -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(channel_validation, "_probe_tcp", lambda *_args, **_kwargs: None) - - payload = 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 payload["status"] == "connected" - assert payload["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( - channel_validation.socket, - "create_connection", - lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"), - ) - - payload = 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 payload["checks"] if check["status"] == "warn"] - assert len(warnings) == 2 - assert all("private/internal" in message for message in warnings) - - -def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None: - connected: list[tuple[str, int]] = [] - - class FakeSocket: - def __enter__(self): - return self - - def __exit__(self, *_args): - return None - - monkeypatch.setattr( - channel_validation, - "resolve_url_target", - lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)), - ) - monkeypatch.setattr( - channel_validation.socket, - "create_connection", - lambda target, **_kwargs: connected.append(target) or FakeSocket(), - ) - - channel_validation._probe_tcp("mail.example.com", 2525) - - assert connected == [("203.0.113.10", 2525)] - - -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) - - payload = validate_channel_config("dingtalk", {}) - - assert payload["status"] == "configured" - assert payload["can_enable"] is True - assert any(check["status"] == "skipped" for check in payload["checks"]) - - -@pytest.mark.parametrize( - ("credentials", "expected_status", "expected_missing"), - [ - ({}, "needs_setup", "password_or_accessToken"), - ({"channels.matrix.accessToken": "token"}, "needs_setup", "deviceId"), - ({"channels.matrix.password": "secret"}, "configured", None), - ( - { - "channels.matrix.accessToken": "token", - "channels.matrix.deviceId": "DEVICE", - }, - "configured", - None, - ), - ], -) -def test_validate_matrix_requires_a_complete_login_method( - tmp_path, - monkeypatch: pytest.MonkeyPatch, - credentials: dict[str, str], - expected_status: str, - expected_missing: str | None, -) -> None: - config_path = tmp_path / "config.json" - save_config(Config(), config_path) - monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) - - payload = validate_channel_config( - "matrix", - { - "channels.matrix.homeserver": "https://matrix.example", - "channels.matrix.userId": "@nanobot:matrix.example", - **credentials, - }, - ) - - assert payload["status"] == expected_status - assert payload["can_enable"] is (expected_status == "configured") - if expected_missing is None: - assert payload["missing_fields"] == [] - else: - assert expected_missing in payload["missing_fields"] diff --git a/webui/eslint.config.js b/webui/eslint.config.js index f01aa2e0..1107c78e 100644 --- a/webui/eslint.config.js +++ b/webui/eslint.config.js @@ -13,7 +13,7 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, { - files: ["src/**/*.{ts,tsx}"], + files: ["webui/src/**/*.{ts,tsx}", "nanobot/channels/*/webui/**/*.{ts,tsx}"], languageOptions: { ecmaVersion: 2020, globals: { diff --git a/webui/package.json b/webui/package.json index aad87be7..acbc11af 100644 --- a/webui/package.json +++ b/webui/package.json @@ -9,7 +9,7 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint src --max-warnings 0" + "lint": "cd .. && eslint --config webui/eslint.config.js webui/src \"nanobot/channels/*/webui/**/*.{ts,tsx}\" --max-warnings 0" }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.4", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 96c3494a..c051d2ed 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { channelUiPresentation } from "@/channel-plugins/registry"; import { DeleteConfirm } from "@/components/DeleteConfirm"; import { RenameChatDialog } from "@/components/RenameChatDialog"; import { Sidebar } from "@/components/Sidebar"; @@ -95,106 +96,6 @@ type ShellRoute = { settingsSection: SettingsSectionKey; }; -type PairingChannelPresentation = { - label: string; - initials: string; - color: string; - logoUrl?: string; -}; - -const PAIRING_CHANNEL_PRESENTATION: Record = { - dingtalk: { - label: "DingTalk", - initials: "DT", - color: "#FF6A00", - logoUrl: "https://www.dingtalk.com/favicon.ico", - }, - discord: { - label: "Discord", - initials: "DC", - color: "#5865F2", - logoUrl: "https://discord.com/favicon.ico", - }, - email: { - label: "Email", - initials: "EM", - color: "#EA4335", - logoUrl: "https://gmail.com/favicon.ico", - }, - feishu: { - label: "Feishu", - initials: "FS", - color: "#3370FF", - logoUrl: "https://www.feishu.cn/favicon.ico", - }, - lark: { - label: "Lark", - initials: "LK", - color: "#3370FF", - logoUrl: "https://www.larksuite.com/favicon.ico", - }, - matrix: { - label: "Matrix", - initials: "M", - color: "#111827", - logoUrl: "https://matrix.org/favicon.ico", - }, - msteams: { - label: "Microsoft Teams", - initials: "MT", - color: "#6264A7", - logoUrl: "https://www.microsoft.com/favicon.ico", - }, - napcat: { - label: "NapCat", - initials: "NC", - color: "#7C3AED", - logoUrl: "https://napneko.github.io/favicon.ico", - }, - qq: { - label: "QQ", - initials: "QQ", - color: "#12B7F5", - logoUrl: "https://im.qq.com/favicon.ico", - }, - signal: { - label: "Signal", - initials: "SG", - color: "#3A76F0", - logoUrl: "https://signal.org/favicon.ico", - }, - slack: { - label: "Slack", - initials: "SL", - color: "#611F69", - logoUrl: "https://slack.com/favicon.ico", - }, - telegram: { - label: "Telegram", - initials: "TG", - color: "#229ED9", - logoUrl: "https://telegram.org/favicon.ico", - }, - wecom: { - label: "WeCom", - initials: "WC", - color: "#2F7DFF", - logoUrl: "https://work.weixin.qq.com/favicon.ico", - }, - weixin: { - label: "WeChat", - initials: "WX", - color: "#07C160", - logoUrl: "https://weixin.qq.com/favicon.ico", - }, - whatsapp: { - label: "WhatsApp", - initials: "WA", - color: "#25D366", - logoUrl: "https://www.whatsapp.com/favicon.ico", - }, -}; - const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [ "overview", "appearance", @@ -624,11 +525,9 @@ function PairingCodePopup({ } function PairingChannelBadge({ channel }: { channel: string }) { - const key = pairingChannelKey(channel); - const presentation = PAIRING_CHANNEL_PRESENTATION[key]; - const label = presentation?.label ?? channelLabel(channel); - const initials = presentation?.initials ?? label.slice(0, 2).toUpperCase(); - const color = presentation?.color ?? "#10B981"; + const presentation = pairingChannelPresentation(channel); + const initials = presentation.initials; + const color = presentation.color; const logoUrls = useMemo( () => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl], @@ -765,8 +664,18 @@ function pairingChannelKey(channel: string): string { } function channelLabel(channel: string): string { + return pairingChannelPresentation(channel).label; +} + +function pairingChannelPresentation(channel: string) { const key = pairingChannelKey(channel); - return PAIRING_CHANNEL_PRESENTATION[key]?.label ?? channel; + const plugin = channelUiPresentation(key); + return { + label: plugin?.displayName ?? channel, + initials: plugin?.initials ?? channel.slice(0, 2).toUpperCase(), + color: plugin?.color ?? "#10B981", + logoUrl: plugin?.logoUrl, + }; } function formatPairingExpiry(seconds: number | null | undefined): string { diff --git a/webui/src/channel-plugins/i18n.ts b/webui/src/channel-plugins/i18n.ts new file mode 100644 index 00000000..95b8a2b5 --- /dev/null +++ b/webui/src/channel-plugins/i18n.ts @@ -0,0 +1,51 @@ +import type { TFunction } from "i18next"; + +export type ChannelFieldMessages = { + label: string; + placeholder?: string; + help?: string; + choices?: Record; +}; + +export type ChannelMessages = { + displayName?: string; + description: string; + requirements: string; + setup: { + primaryAction?: string; + docsLabel?: string; + officialLabel?: string; + summary?: string; + tryIt?: string; + steps: string[]; + fields?: Record; + actions?: Record; + presets?: Record; + }; + custom?: Record; +}; + +export type ChannelTranslator = ( + key: string, + fallback: string, + values?: Record, +) => string; + +export function channelNamespace(channel: string): string { + return `channel-${channel}`; +} + +export function channelTranslator(t: TFunction, channel: string): ChannelTranslator { + const namespace = channelNamespace(channel); + return (key, fallback, values = {}) => t(key, { + ns: namespace, + defaultValue: fallback, + ...values, + }); +} + +export function channelFieldMessageKey(channel: string, configKey: string): string { + const prefix = `channels.${channel}.`; + const field = configKey.startsWith(prefix) ? configKey.slice(prefix.length) : configKey; + return field.replace(/[^A-Za-z0-9_-]+/g, "_"); +} diff --git a/webui/src/channel-plugins/locale-registry.ts b/webui/src/channel-plugins/locale-registry.ts new file mode 100644 index 00000000..7073ac35 --- /dev/null +++ b/webui/src/channel-plugins/locale-registry.ts @@ -0,0 +1,65 @@ +import type { ChannelMessages } from "@/channel-plugins/i18n"; +import { channelNamespace } from "@/channel-plugins/i18n"; +import { + supportedLocales, + type SupportedLocale, +} from "@/i18n/config"; + +type ChannelMessagesModule = { + default?: ChannelMessages; +}; + +const modules = import.meta.glob( + "../../../nanobot/channels/*/webui/locales/*.json", + { eager: true }, +); + +const translationsByChannel = new Map>(); +const supportedLocaleCodes = new Set(supportedLocales.map(({ code }) => code)); + +for (const [modulePath, module] of Object.entries(modules)) { + const messages = module.default; + if (!messages) continue; + const match = modulePath.match(/nanobot\/channels\/([^/]+)\/webui\/locales\/([^/]+)\.json$/); + if (!match) { + throw new Error(`Cannot derive channel locale identity from '${modulePath}'`); + } + const [, channel, locale] = match; + if (!supportedLocaleCodes.has(locale)) { + throw new Error(`Channel '${channel}' has unsupported locale '${locale}'`); + } + const translations = translationsByChannel.get(channel) ?? new Map(); + if (translations.has(locale as SupportedLocale)) { + throw new Error(`Channel '${channel}' registers locale '${locale}' more than once`); + } + translations.set(locale as SupportedLocale, messages); + translationsByChannel.set(channel, translations); +} + +export function channelLocaleNamespaces(): string[] { + return [...translationsByChannel.keys()].map(channelNamespace); +} + +export function channelLocaleResources(locale: SupportedLocale): Record { + return Object.fromEntries( + [...translationsByChannel.keys()].map((channel) => [ + channelNamespace(channel), + channelLocaleMessages(channel, locale) ?? {}, + ]), + ); +} + +export function channelLocaleMessages( + channel: string, + locale: SupportedLocale, +): ChannelMessages | undefined { + const translations = translationsByChannel.get(channel); + return translations?.get(locale) ?? translations?.get("en"); +} + +export function registeredChannelLocales(): ReadonlyMap< + string, + ReadonlyMap +> { + return translationsByChannel; +} diff --git a/webui/src/channel-plugins/registry.ts b/webui/src/channel-plugins/registry.ts new file mode 100644 index 00000000..edd84f66 --- /dev/null +++ b/webui/src/channel-plugins/registry.ts @@ -0,0 +1,83 @@ +import type { + ChannelUiContribution, + RegisteredChannelUiContribution, +} from "@/channel-plugins/types"; + +type ChannelUiContributionModule = { + default?: ChannelUiContribution; +}; + +const modules = import.meta.glob( + "../../../nanobot/channels/*/webui/**/*.{ts,tsx}", + { + eager: true, + }, +); + +const registrations = new Map(); +const registrationsByChannel = new Map(); +const presentationsByChannel = new Map(); +const translationOwners = new Map(); + +for (const [modulePath, module] of Object.entries(modules)) { + const contribution = module.default; + if (!contribution) continue; + const match = modulePath.match(/nanobot\/channels\/([^/]+)\/(.+)$/); + if (!match) { + throw new Error(`Cannot derive channel UI identity from '${modulePath}'`); + } + const [, channel, webui] = match; + const registration = { channel, webui, contribution }; + if (registrationsByChannel.has(channel)) { + throw new Error(`Channel '${channel}' has more than one UI contribution`); + } + registrations.set(registrationKey(channel, webui), registration); + registrationsByChannel.set(channel, registration); + presentationsByChannel.set(channel, contribution.presentation); + translationOwners.set(channel, channel); + for (const [alias, aliasPresentation] of Object.entries(contribution.aliases ?? {})) { + if (presentationsByChannel.has(alias)) { + throw new Error(`Channel UI alias '${alias}' is registered more than once`); + } + presentationsByChannel.set(alias, { + ...contribution.presentation, + ...aliasPresentation, + }); + translationOwners.set(alias, channel); + } +} + +export function channelUiContribution( + channel: string, + webui: string | undefined, +): ChannelUiContribution | undefined { + if (!webui) return undefined; + return registrations.get(registrationKey(channel, webui))?.contribution; +} + +export function registeredChannelUiContributions(): readonly RegisteredChannelUiContribution[] { + return [...registrations.values()]; +} + +export function channelUiOwner(channel: string): string { + return translationOwners.get(channel) ?? channel; +} + +export function channelUiPresentation( + channel: string, +): ChannelUiContribution["presentation"] | undefined; +export function channelUiPresentation( + channel: string, + webui: string | undefined, +): ChannelUiContribution["presentation"] | undefined; +export function channelUiPresentation( + channel: string, + webui?: string, +): ChannelUiContribution["presentation"] | undefined { + if (arguments.length > 1) return channelUiContribution(channel, webui)?.presentation; + return presentationsByChannel.get(channel); +} + +function registrationKey(channel: string, webui: string): string { + return `${channel}:${webui.replaceAll("\\", "/")}`; +} diff --git a/webui/src/channel-plugins/types.ts b/webui/src/channel-plugins/types.ts new file mode 100644 index 00000000..99360be4 --- /dev/null +++ b/webui/src/channel-plugins/types.ts @@ -0,0 +1,39 @@ +import type { ComponentType } from "react"; + +import type { ChannelPresentation } from "@/components/settings/channels/catalog"; +import type { + NanobotFeatureInfo, + NanobotFeaturesPayload, +} from "@/lib/types"; + +export type ChannelPluginPanelProps = { + token: string; + feature: NanobotFeatureInfo; + actionKey: string | null; + chatAppsDocsUrl?: string; + showBrandLogos: boolean; + onAction: (action: "enable" | "disable", name: string) => void; + onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; +}; + +export type ChannelPluginConnectFlowProps = { + token: string; + feature: NanobotFeatureInfo; + idleLabel?: string; + connectRequestId?: number; + onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; +}; + +export type ChannelUiContribution = { + presentation: ChannelPresentation; + aliases?: Record>; + Panel?: ComponentType; + ConnectFlow?: ComponentType; + canConnectBeforeConfigured?: boolean; +}; + +export type RegisteredChannelUiContribution = { + channel: string; + webui: string; + contribution: ChannelUiContribution; +}; diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 6615d572..52281ed5 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -61,14 +61,16 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { channelUiPresentation } from "@/channel-plugins/registry"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings"; import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap"; import { ToggleButton } from "@/components/settings/ToggleButton"; import { - channelDisplayName, + channelIsRunning, channelMatchesFilter, channelSearchText, + localizedChannelDisplayName, type ChannelFilter, } from "@/components/settings/channels/ChannelIdentity"; import { @@ -746,23 +748,37 @@ export function SettingsView({ useEffect(() => { if (!["channels", "models", "browser", "runtime"].includes(activeSection)) return; let cancelled = false; - setNanobotFeaturesLoading(true); - fetchNanobotFeatures(token) - .then((payload) => { + const refresh = async (showLoading = false) => { + if (showLoading) setNanobotFeaturesLoading(true); + try { + const payload = await fetchNanobotFeatures(token); if (!cancelled) { setNanobotFeatures(payload); setNanobotFeaturesError(null); } - }) - .catch((err) => { + } catch (err) { const message = (err as Error).message; if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message); - }) - .finally(() => { - if (!cancelled) setNanobotFeaturesLoading(false); - }); + } finally { + if (!cancelled && showLoading) setNanobotFeaturesLoading(false); + } + }; + void refresh(true); + const interval = activeSection === "channels" + ? window.setInterval(() => void refresh(false), 5000) + : null; + const refreshOnFocus = () => { + if (activeSection === "channels" && document.visibilityState !== "hidden") { + void refresh(false); + } + }; + window.addEventListener("focus", refreshOnFocus); + document.addEventListener("visibilitychange", refreshOnFocus); return () => { cancelled = true; + if (interval !== null) window.clearInterval(interval); + window.removeEventListener("focus", refreshOnFocus); + document.removeEventListener("visibilitychange", refreshOnFocus); }; }, [activeSection, token]); @@ -4890,22 +4906,9 @@ const AUTOMATION_SEARCH_FIELDS = new Set([ "status", ]); -const AUTOMATION_CHANNEL_LABELS: Record = { +const HOST_AUTOMATION_CHANNEL_LABELS: Record = { api: "API", cli: "CLI", - dingtalk: "DingTalk", - discord: "Discord", - email: "Email", - feishu: "Feishu", - matrix: "Matrix", - msteams: "Microsoft Teams", - qq: "QQ", - slack: "Slack", - telegram: "Telegram", - wechat: "WeChat", - wecom: "WeCom", - weixin: "WeChat", - whatsapp: "WhatsApp", }; function parseAutomationSearchQuery(query: string): AutomationSearchToken[] { @@ -4974,7 +4977,7 @@ function automationOriginSearchParts(job: SessionAutomationJob): Array) => string, ): string { const key = channel.trim().toLowerCase(); - return AUTOMATION_CHANNEL_LABELS[key] - ? tx(`settings.automations.channels.${key}`, AUTOMATION_CHANNEL_LABELS[key]) + const displayName = automationChannelDisplayName(key); + return displayName + ? tx(`settings.automations.channels.${key}`, displayName) : channel; } +function automationChannelDisplayName(channel: string): string | undefined { + const key = channel.trim().toLowerCase(); + return channelUiPresentation(key)?.displayName ?? HOST_AUTOMATION_CHANNEL_LABELS[key]; +} + function formatAutomationSchedule( job: SessionAutomationJob, locale: string, @@ -5331,8 +5340,6 @@ function RestartRequiredNotice({ ); } -const HIDDEN_WEBUI_CHANNELS = new Set(["mochat"]); - function ChannelsSettings({ token, nanobotFeatures, @@ -5376,17 +5383,19 @@ function ChannelsSettings({ const [compactDetailOpen, setCompactDetailOpen] = useState(false); const allChannels = (nanobotFeatures?.features ?? []) .filter((feature) => feature.type === "channel") - .filter((feature) => !HIDDEN_WEBUI_CHANNELS.has(feature.name)) - .filter((feature) => !normalizedQuery || channelSearchText(feature).includes(normalizedQuery)) + .filter((feature) => feature.settings_visible !== false) + .filter((feature) => !normalizedQuery || channelSearchText(feature, t).includes(normalizedQuery)) .sort((left, right) => { const rank = Number(!left.ready) - Number(!right.ready); - return rank || channelDisplayName(left).localeCompare(channelDisplayName(right)); + return rank || localizedChannelDisplayName(left, t).localeCompare( + localizedChannelDisplayName(right, t), + ); }); const channels = allChannels.filter((feature) => channelMatchesFilter(feature, filter)); const [selectedChannelName, setSelectedChannelName] = useState(null); const selectedChannel = channels.find((feature) => feature.name === selectedChannelName) ?? channels[0] ?? null; - const enabledCount = allChannels.filter((feature) => feature.enabled).length; + const enabledCount = allChannels.filter(channelIsRunning).length; const offCount = Math.max(0, allChannels.length - enabledCount); const filterOptions: Array<{ value: ChannelFilter; label: string; count: number }> = [ { value: "all", label: tx("settings.channels.filterAll", "All"), count: allChannels.length }, diff --git a/webui/src/components/settings/channels/ChannelIdentity.tsx b/webui/src/components/settings/channels/ChannelIdentity.tsx index 06ad252e..f565fa06 100644 --- a/webui/src/components/settings/channels/ChannelIdentity.tsx +++ b/webui/src/components/settings/channels/ChannelIdentity.tsx @@ -2,25 +2,121 @@ import { useMemo, type ReactNode } from "react"; import type { useTranslation } from "react-i18next"; import { - CHANNEL_PRESENTATION, - type ChannelSetupPresentation, + channelFieldMessageKey, + channelTranslator, +} from "@/channel-plugins/i18n"; +import { channelLocaleMessages } from "@/channel-plugins/locale-registry"; +import { + channelUiOwner, + channelUiPresentation, +} from "@/channel-plugins/registry"; +import type { + ChannelConfigField, + ChannelSetupPresentation, } from "@/components/settings/channels/catalog"; import { useLogoFallback } from "@/hooks/useLogoFallback"; +import { normalizeLocale } from "@/i18n/config"; import { logoFallbackUrls } from "@/lib/provider-brand"; -import type { NanobotFeatureInfo } from "@/lib/types"; +import type { ChannelRuntimeStatus, NanobotFeatureInfo } from "@/lib/types"; export type ChannelFilter = "all" | "on" | "off"; -export function channelSetup(feature: NanobotFeatureInfo): ChannelSetupPresentation { - return CHANNEL_PRESENTATION[feature.name]?.setup ?? { +export function channelSetup( + feature: NanobotFeatureInfo, + locale = "en", +): ChannelSetupPresentation { + const definition = channelUiPresentation(feature.name, feature.webui)?.setup; + const owner = channelUiOwner(feature.name); + const messages = channelLocaleMessages(owner, normalizeLocale(locale)); + const setupMessages = messages?.setup; + const localizeField = (key: string): ChannelConfigField => { + const copy = setupMessages?.fields?.[channelFieldMessageKey(feature.name, key)]; + return { + key, + label: copy?.label ?? fieldLabel(key.split(".").at(-1) ?? key), + placeholder: copy?.placeholder, + help: copy?.help, + }; + }; + const presentation: ChannelSetupPresentation = { + ...definition, + primaryActionLabel: setupMessages?.primaryAction, + docsLabel: setupMessages?.docsLabel, + officialLabel: setupMessages?.officialLabel, summary: - "Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.", - steps: [ + setupMessages?.summary + ?? "Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.", + tryIt: setupMessages?.tryIt, + steps: setupMessages?.steps ?? [ `Open ~/.nanobot/config.json and find channels.${feature.name}.`, "Add the credentials required by that platform, using the channel documentation as the source of truth.", "Restart nanobot, then send a small test message from that platform.", ], + fields: definition?.fields?.map((field) => localizeField(field.key)), + manualFields: definition?.manualFields?.map((field) => localizeField(field.key)), + actions: definition?.actions?.map((action) => ({ + ...action, + label: setupMessages?.actions?.[action.id] ?? fieldLabel(action.id), + })), + presets: definition?.presets?.map((preset) => ({ + ...preset, + label: setupMessages?.presets?.[preset.id] ?? fieldLabel(preset.id), + })), }; + const contract = feature.setup; + if (!contract) return presentation; + + const primaryFields = new Map( + (presentation.fields ?? []).map((field) => [field.key, field]), + ); + const manualFields = new Map( + (presentation.manualFields ?? []).map((field) => [field.key, field]), + ); + const authoritativeFields = contract.fields.map((field): ChannelConfigField => { + const local = primaryFields.get(field.key) ?? manualFields.get(field.key); + const copy = local ?? localizeField(field.key); + const choiceLabels = setupMessages?.fields?.[ + channelFieldMessageKey(feature.name, field.key) + ]?.choices ?? {}; + const choices = field.kind === "bool" ? ["true", "false"] : field.choices; + return { + ...copy, + key: field.key, + label: copy.label, + secret: field.kind === "secret", + optional: !field.required, + inputType: field.kind === "int" ? "number" : undefined, + defaultValue: field.default_value, + options: + field.kind === "enum" || field.kind === "bool" + ? choices.map((choice) => ({ + value: choice, + label: choiceLabels[choice] ?? fieldLabel(choice), + })) + : undefined, + }; + }); + const manualKeys = new Set(manualFields.keys()); + const fields = authoritativeFields.filter((field) => !manualKeys.has(field.key)); + const manual = authoritativeFields.filter((field) => manualKeys.has(field.key)); + + return { + ...presentation, + officialUrl: contract.official_url, + officialLabel: + presentation.officialLabel + ?? (contract.official_url ? "Open official setup" : undefined), + fields: fields.length ? fields : undefined, + manualFields: manual.length ? manual : undefined, + }; +} + +function fieldLabel(value: string): string { + const spaced = value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .trim(); + return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : value; } export function ChannelLogo({ @@ -30,7 +126,7 @@ export function ChannelLogo({ feature: NanobotFeatureInfo; showBrandLogos: boolean; }) { - const presentation = CHANNEL_PRESENTATION[feature.name]; + const presentation = channelUiPresentation(feature.name, feature.webui); const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase(); const color = presentation?.color ?? "#6B7280"; const Icon = presentation?.icon; @@ -80,55 +176,108 @@ export function ChannelLogo({ } export function channelDisplayName(feature: NanobotFeatureInfo): string { - return CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name; + return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name; +} + +export function localizedChannelDisplayName( + feature: NanobotFeatureInfo, + t: ReturnType["t"], +): string { + const fallback = channelDisplayName(feature); + return channelTranslator(t, channelUiOwner(feature.name))("displayName", fallback); } export function channelDescription(feature: NanobotFeatureInfo, t: ReturnType["t"]): string { const fallback = - CHANNEL_PRESENTATION[feature.name]?.description ?? `Use nanobot from ${channelDisplayName(feature)}.`; - return t(`settings.channels.items.${feature.name}.description`, { defaultValue: fallback }); + return channelTranslator(t, channelUiOwner(feature.name))("description", fallback); } export function channelRequirements(feature: NanobotFeatureInfo, t: ReturnType["t"]): string { const fallback = - CHANNEL_PRESENTATION[feature.name]?.requirements ?? "Channel credentials and gateway settings"; - return t(`settings.channels.items.${feature.name}.requirements`, { defaultValue: fallback }); + return channelTranslator(t, channelUiOwner(feature.name))("requirements", fallback); } export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean { - if (filter === "on") return feature.enabled; - if (filter === "off") return !feature.enabled; + if (filter === "on") return channelIsRunning(feature); + if (filter === "off") return !channelIsRunning(feature); return true; } +export function channelIsRunning(feature: NanobotFeatureInfo): boolean { + return feature.runtime_status === "running"; +} + +export function channelToggleChecked(feature: NanobotFeatureInfo): boolean { + return feature.runtime_status === "running" || feature.runtime_status === "starting"; +} + export function channelStatusLabel( feature: NanobotFeatureInfo, tx: (key: string, fallback: string) => string, ): string { - if (feature.enabled) return tx("settings.values.on", "On"); + if (feature.runtime_status === "failed") { + return tx("settings.channels.runtimeFailed", "Failed"); + } + if (feature.runtime_status === "starting") { + return tx("settings.channels.runtimeStarting", "Starting"); + } + if (channelIsRunning(feature)) return tx("settings.values.on", "On"); + if (feature.enabled) return tx("settings.channels.runtimeStopped", "Not running"); return tx("settings.values.off", "Off"); } -export function channelSearchText(feature: NanobotFeatureInfo): string { +export function channelSearchText( + feature: NanobotFeatureInfo, + t?: ReturnType["t"], +): string { return [ + t ? localizedChannelDisplayName(feature, t) : undefined, channelDisplayName(feature), feature.display_name, feature.name, feature.status, - CHANNEL_PRESENTATION[feature.name]?.description, - CHANNEL_PRESENTATION[feature.name]?.requirements, + t ? channelDescription(feature, t) : undefined, + t ? channelRequirements(feature, t) : undefined, ] .join(" ") .toLowerCase(); } -export function ChannelStatusBadge({ children }: { children: ReactNode }) { +export function ChannelStatusBadge({ + children, + status, +}: { + children: ReactNode; + status?: ChannelRuntimeStatus; +}) { return ( - + {children} ); } + +export function ChannelRuntimeError({ + message, + className = "mt-3", +}: { + message?: string; + className?: string; +}) { + if (!message) return null; + return ( +
+ {message} +
+ ); +} diff --git a/webui/src/components/settings/channels/FeishuAssistantsPanel.tsx b/webui/src/components/settings/channels/ChannelInstancesPanel.tsx similarity index 57% rename from webui/src/components/settings/channels/FeishuAssistantsPanel.tsx rename to webui/src/components/settings/channels/ChannelInstancesPanel.tsx index 500a96f3..3233b983 100644 --- a/webui/src/components/settings/channels/FeishuAssistantsPanel.tsx +++ b/webui/src/components/settings/channels/ChannelInstancesPanel.tsx @@ -1,12 +1,10 @@ -import { useEffect, useMemo, useState } from "react"; -import { ChevronDown, Loader2, RotateCcw } from "lucide-react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { ChevronDown, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { channelUiPresentation } from "@/channel-plugins/registry"; import { ToggleButton } from "@/components/settings/ToggleButton"; -import { - CHANNEL_PRESENTATION, - type ChannelConfigField, -} from "@/components/settings/channels/catalog"; +import type { ChannelConfigField } from "@/components/settings/channels/catalog"; import { CredentialForm, channelValidationStatusClass, @@ -16,12 +14,12 @@ import { } from "@/components/settings/channels/CredentialForm"; import { ChannelLogo, + ChannelRuntimeError, ChannelStatusBadge, - channelDisplayName, channelSetup, channelStatusLabel, + localizedChannelDisplayName, } from "@/components/settings/channels/ChannelIdentity"; -import { FeishuConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow"; import { ChannelGuideLink, ChannelSetupSteps, @@ -41,34 +39,61 @@ import type { } from "@/lib/types"; import { cn } from "@/lib/utils"; -export function FeishuAssistantsPanel({ +export type ChannelInstancesPanelCustomization = { + countLabel?: (runningCount: number) => string; + toggleAriaLabel?: (instance: NanobotChannelInstanceInfo) => string; + configuredLabel?: string; + needsSetupLabel?: string; + renderInstanceSummary?: (instance: NanobotChannelInstanceInfo) => ReactNode; + renderInstanceAction?: (instance: NanobotChannelInstanceInfo) => ReactNode; + footer?: ReactNode; +}; + +export function ChannelInstancesPanel({ token, feature, showBrandLogos, chatAppsDocsUrl, + instances: providedInstances, onFeaturesUpdate, + customization = {}, }: { token: string; feature: NanobotFeatureInfo; showBrandLogos: boolean; chatAppsDocsUrl?: string; + instances?: NanobotChannelInstanceInfo[]; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; + customization?: ChannelInstancesPanelCustomization; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const instances = feishuFeatureInstances(feature); + const displayName = localizedChannelDisplayName(feature, t); + const instances = providedInstances ?? feature.instances ?? []; const [selectedId, setSelectedId] = useState(null); const [busyInstanceId, setBusyInstanceId] = useState(null); const [notice, setNotice] = useState(null); const selected = selectedId ? instances.find((instance) => instance.id === selectedId) : undefined; - const setup = channelSetup(feature); - const manualFields = setup.manualFields ?? []; + const setup = useMemo( + () => channelSetup(feature, i18n.resolvedLanguage ?? i18n.language), + [feature.name, feature.setup, i18n.language, i18n.resolvedLanguage], + ); + const instanceFields = useMemo( + () => channelInstanceFields(feature, setup.fields, setup.manualFields), + [feature, setup.fields, setup.manualFields], + ); const [fieldValues, setFieldValues] = useState>(() => - feishuInstanceFieldValues(manualFields, selected), + defaultChannelFieldValues(instanceFields, selected?.config_values), ); const [visibleSecrets, setVisibleSecrets] = useState>({}); const [savingFields, setSavingFields] = useState(false); - const connectedAssistantCount = instances.filter((instance) => instance.configured).length; + const configuredCount = instances.filter((instance) => instance.configured).length; + const runningCount = instances.filter((instance) => instance.runtime_status === "running").length; + const selectedValuesKey = JSON.stringify(selected?.config_values ?? {}); + const selectedConfiguredFields = useMemo( + () => new Set(selected?.configured_fields ?? []), + [selected?.configured_fields], + ); useEffect(() => { if (selectedId && !instances.some((instance) => instance.id === selectedId)) { @@ -77,37 +102,17 @@ export function FeishuAssistantsPanel({ }, [instances, selectedId]); useEffect(() => { - setFieldValues(feishuInstanceFieldValues(manualFields, selected)); + setFieldValues(defaultChannelFieldValues(instanceFields, selected?.config_values)); setVisibleSecrets({}); - }, [ - manualFields, - selected?.allow_from, - selected?.app_id, - selected?.domain, - selected?.group_policy, - selected?.id, - ]); + }, [instanceFields, selected?.id, selectedValuesKey]); const toggleInstance = async (instance: NanobotChannelInstanceInfo, checked: boolean) => { setBusyInstanceId(instance.id); setNotice(null); try { const payload = checked - ? await enableNanobotFeature(token, "feishu", { instanceId: instance.id }) - : await disableNanobotFeature(token, "feishu", { instanceId: instance.id }); - onFeaturesUpdate(payload); - } catch (err) { - setNotice((err as Error).message); - } finally { - setBusyInstanceId(null); - } - }; - - const reconnectInstance = async (instance: NanobotChannelInstanceInfo) => { - setBusyInstanceId(instance.id); - setNotice(null); - try { - const payload = await enableNanobotFeature(token, "feishu", { instanceId: instance.id }); + ? await enableNanobotFeature(token, feature.name, { instanceId: instance.id }) + : await disableNanobotFeature(token, feature.name, { instanceId: instance.id }); onFeaturesUpdate(payload); } catch (err) { setNotice((err as Error).message); @@ -123,8 +128,8 @@ export function FeishuAssistantsPanel({ try { const payload = await configureChannel( token, - "feishu", - channelValuesForSave(manualFields, fieldValues), + feature.name, + channelValuesForSave(instanceFields, fieldValues), { enable: selected.enabled, instanceId: selected.id }, ); if (payload.nanobot_features) { @@ -145,16 +150,26 @@ export function FeishuAssistantsPanel({

- {channelDisplayName(feature)} + {displayName}

- {feishuAssistantCountLabel(connectedAssistantCount, tx)} + {customization.countLabel?.(runningCount) + ?? t("settings.channels.configuredInstances", { + count: configuredCount, + defaultValue: `${configuredCount} instances configured`, + })}

- {channelStatusLabel(feature, tx)} +
+ + {channelStatusLabel(feature, tx)} + +
+ +
{instances.map((instance) => { const expanded = selected?.id === instance.id; @@ -177,14 +192,13 @@ export function FeishuAssistantsPanel({ } aria-expanded={expanded} > - - {feishuInstanceDisplayName(instance)} + {channelInstanceDisplayName(instance)} ) : null} void toggleInstance(instance, checked)} />
@@ -216,41 +234,17 @@ export function FeishuAssistantsPanel({

- {maskFeishuAppId(instance.app_id) || tx("settings.channels.noAppId", "No App ID")} + {customization.renderInstanceSummary?.(instance) ?? instance.id}

- -
- {instance.configured ? ( -
- -
- ) : ( - - )} + + {customization.renderInstanceAction?.(instance)}
} /> - {manualFields.length ? ( + {instanceFields.length ? (
@@ -272,10 +266,17 @@ export function FeishuAssistantsPanel({ /> -
+
{ + event.preventDefault(); + void saveSelectedInstanceSettings(); + }} + > setFieldValues((current) => ({ ...current, [key]: value })) @@ -287,11 +288,10 @@ export function FeishuAssistantsPanel({ />
-
+
) : null} @@ -310,25 +310,7 @@ export function FeishuAssistantsPanel({ })} -
-
- {tx("settings.channels.createFeishuAssistant", "Create another assistant")} -
-

- {tx( - "settings.channels.createFeishuAssistantHint", - "Create a separate Feishu bot for another team, space, or workflow.", - )} -

- -
+ {customization.footer} {notice ? (
@@ -339,45 +321,44 @@ export function FeishuAssistantsPanel({ ); } -function feishuFeatureInstances(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo[] { - if (feature.instances?.length) return feature.instances; - return [{ - id: "default", - name: "nanobot", - domain: "feishu", - enabled: feature.enabled, - configured: Boolean(feature.configured), - app_id: "", - }]; -} - -function feishuAssistantCountLabel( - count: number, - tx: (key: string, fallback: string) => string, -): string { - if (count === 0) { - return tx("settings.channels.noFeishuAssistants", "No assistant connected"); - } - if (count === 1) { - return tx("settings.channels.oneFeishuAssistant", "1 assistant connected"); - } - return tx("settings.channels.manyFeishuAssistants", `${count} assistants connected`); -} - -function feishuInstanceDisplayName(instance: NanobotChannelInstanceInfo): string { +function channelInstanceDisplayName(instance: NanobotChannelInstanceInfo): string { const displayName = instance.display_name?.trim(); if (displayName) return displayName; const localName = instance.name?.trim(); if (localName) return localName; - return instance.id === "default" ? "nanobot" : "nanobot"; + return instance.id; } -function FeishuAssistantConnectionBadge({ instance }: { instance: NanobotChannelInstanceInfo }) { +function instanceToggleChecked(instance: NanobotChannelInstanceInfo): boolean { + return instance.runtime_status === "running" || instance.runtime_status === "starting"; +} + +function ChannelInstanceStatusBadge({ + instance, + configuredLabel, + needsSetupLabel, +}: { + instance: NanobotChannelInstanceInfo; + configuredLabel?: string; + needsSetupLabel?: string; +}) { const { t } = useTranslation(); - const status = instance.configured ? "connected" : "needs_setup"; - const label = instance.configured - ? t("settings.channels.feishuConfigured", { defaultValue: "Connected" }) - : t("settings.channels.feishuNotConfigured", { defaultValue: "Needs authorization" }); + let status = instance.configured ? "configured" : "needs_setup"; + let label = instance.configured + ? t("settings.channels.instanceConfigured", { defaultValue: "Configured" }) + : needsSetupLabel ?? t("settings.channels.instanceNeedsSetup", { defaultValue: "Needs setup" }); + if (instance.runtime_status === "failed") { + status = "invalid"; + label = t("settings.channels.runtimeFailed", { defaultValue: "Failed" }); + } else if (instance.runtime_status === "starting") { + label = t("settings.channels.runtimeStarting", { defaultValue: "Starting" }); + } else if (instance.enabled && instance.runtime_status !== "running") { + label = t("settings.channels.runtimeStopped", { defaultValue: "Not running" }); + } else if (instance.runtime_status === "running") { + status = "connected"; + label = configuredLabel + ?? t("settings.channels.validation.connected", { defaultValue: "Connected" }); + } return ( logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(fallbackLogoUrls); @@ -411,9 +390,6 @@ function FeishuAssistantAvatar({ const Icon = presentation?.icon; const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase(); const color = presentation?.color ?? "#3370FF"; - const frameClass = size === "lg" ? "h-11 w-11" : "h-9 w-9"; - const fallbackImageClass = size === "lg" ? "h-6 w-6" : "h-5 w-5"; - const iconClass = size === "lg" ? "h-5 w-5" : "h-4 w-4"; useEffect(() => { setAvatarFailed(false); @@ -421,10 +397,7 @@ function FeishuAssistantAvatar({ return ( @@ -443,12 +416,12 @@ function FeishuAssistantAvatar({ alt="" decoding="async" loading="lazy" - className={cn("object-contain", fallbackImageClass)} + className="h-6 w-6 object-contain" onLoad={onLogoLoad} onError={onLogoError} /> ) : Icon ? ( - + ) : ( initials )} @@ -456,23 +429,17 @@ function FeishuAssistantAvatar({ ); } -function maskFeishuAppId(appId: string | undefined): string { - if (!appId) return ""; - if (appId.length <= 10) return appId; - return `${appId.slice(0, 7)}...${appId.slice(-4)}`; -} - -function feishuInstanceFieldValues( - fields: ChannelConfigField[], - instance: NanobotChannelInstanceInfo | undefined, -): Record { - const values = defaultChannelFieldValues(fields); - if (!instance) return values; - values["channels.feishu.appId"] = instance.app_id ?? ""; - values["channels.feishu.appSecret"] = ""; - values["channels.feishu.domain"] = instance.domain ?? values["channels.feishu.domain"] ?? "feishu"; - values["channels.feishu.groupPolicy"] = - instance.group_policy ?? values["channels.feishu.groupPolicy"] ?? "mention"; - values["channels.feishu.allowFrom"] = (instance.allow_from ?? []).join(", "); - return values; +function channelInstanceFields( + feature: NanobotFeatureInfo, + fields: ChannelConfigField[] | undefined, + manualFields: ChannelConfigField[] | undefined, +): ChannelConfigField[] { + const available = new Map( + [...(fields ?? []), ...(manualFields ?? [])].map((field) => [field.key, field]), + ); + if (!feature.setup) return [...available.values()]; + return feature.setup.fields.flatMap((field) => { + const resolved = available.get(field.key); + return resolved ? [resolved] : []; + }); } diff --git a/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx b/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx index 29f2dc1f..70bc830f 100644 --- a/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx +++ b/webui/src/components/settings/channels/ChannelQrConnectFlow.tsx @@ -26,25 +26,29 @@ export type ChannelQrConnectLabels = { connect: string; }; +export type ChannelConnectStartOptions = { + domain?: string; + instanceId?: string; + mode?: "replace" | "create"; + force?: boolean; +}; + export function ChannelQrConnectFlow({ token, channelName, startOptions = {}, idleLabel, connectRequestId, + forceOnRepeat = false, labels, onFeaturesUpdate, }: { token: string; - channelName: "feishu" | "weixin"; - startOptions?: { - domain?: "feishu" | "lark"; - instanceId?: string; - mode?: "replace" | "create"; - force?: boolean; - }; + channelName: string; + startOptions?: ChannelConnectStartOptions; idleLabel?: string; connectRequestId?: number; + forceOnRepeat?: boolean; labels: ChannelQrConnectLabels; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; }) { @@ -232,7 +236,7 @@ export function ChannelQrConnectFlow({ 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 start(channelName === "weixin" && succeeded)} + onClick={() => void start(forceOnRepeat && succeeded)} disabled={!canStart} > {busy ? ( @@ -252,83 +256,3 @@ export function ChannelQrConnectFlow({
); } -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 = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - return ( - - ); -} - -export function WeixinConnectFlow({ - token, - idleLabel, - connectRequestId, - onFeaturesUpdate, -}: { - token: string; - idleLabel?: string; - connectRequestId?: number; - onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; -}) { - const { t } = useTranslation(); - const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - return ( - - ); -} diff --git a/webui/src/components/settings/channels/ChannelSetupPanel.tsx b/webui/src/components/settings/channels/ChannelSetupPanel.tsx index dc955c2f..8c007444 100644 --- a/webui/src/components/settings/channels/ChannelSetupPanel.tsx +++ b/webui/src/components/settings/channels/ChannelSetupPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type ComponentType } from "react"; import { Check, ChevronDown, @@ -9,6 +9,8 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { channelUiContribution } from "@/channel-plugins/registry"; +import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types"; import { ToggleButton } from "@/components/settings/ToggleButton"; import { type ChannelProviderPreset, @@ -21,17 +23,15 @@ import { } from "@/components/settings/channels/CredentialForm"; import { ChannelLogo, + ChannelRuntimeError, ChannelStatusBadge, channelDescription, - channelDisplayName, channelRequirements, channelSetup, channelStatusLabel, + channelToggleChecked, + localizedChannelDisplayName, } from "@/components/settings/channels/ChannelIdentity"; -import { - FeishuConnectFlow, - WeixinConnectFlow, -} from "@/components/settings/channels/ChannelQrConnectFlow"; import { ChannelProviderPresets, ChannelSetupActions, @@ -41,7 +41,7 @@ import { ChannelValidationChecks, ChannelValidationDetails, } from "@/components/settings/channels/ChannelSetupParts"; -import { FeishuAssistantsPanel } from "@/components/settings/channels/FeishuAssistantsPanel"; +import { ChannelInstancesPanel } from "@/components/settings/channels/ChannelInstancesPanel"; import { Button } from "@/components/ui/button"; import { configureChannel, @@ -68,12 +68,13 @@ export function ChannelCatalogRow({ }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); + const displayName = localizedChannelDisplayName(feature, t); return ( {setup.command ? ( ))} - {CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name} + {channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name} ); } export function ChannelProviderPresets({ - featureName, presets, onApply, }: { - featureName: string; presets: ChannelProviderPreset[]; onApply: (preset: ChannelProviderPreset) => void; }) { @@ -227,15 +226,11 @@ export function ChannelProviderPresets({ return (
- {t(`settings.channels.items.${featureName}.providerPreset`, { - defaultValue: "Provider", - })} + {t("settings.channels.providerPreset", { defaultValue: "Provider" })}
@@ -344,12 +339,10 @@ export function ChannelValidationChecks({ validation }: { validation: ChannelVal } export function ChannelSetupSteps({ - featureName, steps, action, tryIt, }: { - featureName: string; steps: string[]; action?: ReactNode; tryIt?: string; @@ -370,11 +363,7 @@ export function ChannelSetupSteps({ {index + 1} - - {t(`settings.channels.items.${featureName}.setup.steps.${index}`, { - defaultValue: step, - })} - + {step} ))} @@ -384,7 +373,7 @@ export function ChannelSetupSteps({ {tx("settings.channels.tryIt", "Try it")} - {t(`settings.channels.items.${featureName}.setup.tryIt`, { defaultValue: tryIt })} + {tryIt}
) : null} diff --git a/webui/src/components/settings/channels/CredentialForm.tsx b/webui/src/components/settings/channels/CredentialForm.tsx index 46ac36b9..f25dd5b4 100644 --- a/webui/src/components/settings/channels/CredentialForm.tsx +++ b/webui/src/components/settings/channels/CredentialForm.tsx @@ -190,6 +190,7 @@ export function CredentialForm({ ; + +export type ChannelProviderPresetDefinition = Omit; + export type ChannelSetupAction = { id: string; label: string; @@ -59,962 +77,19 @@ export type ChannelConfigOption = { label: string; }; -const GROUP_BEHAVIOR_OPTIONS: ChannelConfigOption[] = [ - { value: "mention", label: "Mention only" }, - { value: "open", label: "All messages" }, -]; - -const GROUP_BEHAVIOR_ALLOWLIST_OPTIONS: ChannelConfigOption[] = [ - ...GROUP_BEHAVIOR_OPTIONS, - { value: "allowlist", label: "Allowlist" }, -]; - -const FEISHU_REGION_OPTIONS: ChannelConfigOption[] = [ - { value: "feishu", label: "Feishu" }, - { value: "lark", label: "Lark" }, -]; - -const BOOLEAN_OPTIONS: ChannelConfigOption[] = [ - { value: "true", label: "On" }, - { value: "false", label: "Off" }, -]; - -const CONSENT_OPTIONS: ChannelConfigOption[] = [ - { value: "true", label: "Granted" }, - { value: "false", label: "Not granted" }, -]; - -const QQ_MESSAGE_FORMAT_OPTIONS: ChannelConfigOption[] = [ - { value: "plain", label: "Plain text" }, - { value: "markdown", label: "Markdown" }, -]; - const NANOBOT_DOCS_URL = "https://nanobot.wiki/docs/latest"; const CHAT_APPS_DOCS_URL = `${NANOBOT_DOCS_URL}/getting-started/chat-apps`; -const SLACK_APPS_URL = "https://api.slack.com/apps"; -const TELEGRAM_BOTFATHER_URL = "https://t.me/BotFather"; -const DISCORD_DEVELOPER_URL = "https://discord.com/developers/applications"; -const GMAIL_APP_PASSWORDS_URL = "https://support.google.com/accounts/answer/185833"; -const FEISHU_OPEN_PLATFORM_URL = "https://open.feishu.cn/app"; -const DINGTALK_OPEN_PLATFORM_URL = "https://open.dingtalk.com/"; -const WECOM_DEVELOPER_URL = "https://developer.work.weixin.qq.com/"; -const QQ_OPEN_PLATFORM_URL = "https://q.qq.com/"; -const MATRIX_CLIENTS_URL = "https://matrix.org/ecosystem/clients/"; -const MATTERMOST_BOT_DOCS_URL = "https://developers.mattermost.com/integrate/reference/bot-accounts/"; -const SIGNAL_CLI_URL = "https://github.com/bbernhard/signal-cli-rest-api"; -const TEAMS_DEVELOPER_URL = "https://dev.teams.microsoft.com/apps"; -const NAPCAT_DOCS_URL = "https://napneko.github.io/"; -export const SLACK_SOCKET_MODE_MANIFEST = `display_information: - name: nanobot -features: - app_home: - home_tab_enabled: false - messages_tab_enabled: true - messages_tab_read_only_enabled: false - bot_user: - display_name: nanobot -oauth_config: - scopes: - bot: - - app_mentions:read - - channels:history - - channels:read - - chat:write - - files:read - - files:write - - groups:history - - groups:read - - im:history - - im:write - - mpim:history - - reactions:write - - users:read -settings: - event_subscriptions: - bot_events: - - app_mention - - message.channels - - message.groups - - message.im - - message.mpim - socket_mode_enabled: true - interactivity: - is_enabled: true`; - -const EMAIL_PROVIDER_PRESETS: ChannelProviderPreset[] = [ - { - id: "gmail", - label: "Gmail", - values: { - "channels.email.imapHost": "imap.gmail.com", - "channels.email.imapPort": "993", - "channels.email.smtpHost": "smtp.gmail.com", - "channels.email.smtpPort": "587", - }, - }, - { - id: "outlook", - label: "Outlook", - values: { - "channels.email.imapHost": "outlook.office365.com", - "channels.email.imapPort": "993", - "channels.email.smtpHost": "smtp.office365.com", - "channels.email.smtpPort": "587", - }, - }, - { - id: "icloud", - label: "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", label: "Custom", values: {} }, -]; - -function chatAppGuideUrl(sectionId: string): string { +export function chatAppGuideUrl(sectionId: string): string { return `${CHAT_APPS_DOCS_URL}#${sectionId}`; } -export function docsUrlWithBase(url: string | undefined, chatAppsDocsUrl?: string): string | undefined { +export function docsUrlWithBase( + url: string | undefined, + chatAppsDocsUrl?: string, +): string | undefined { if (!url || !chatAppsDocsUrl) return url; if (!url.startsWith(CHAT_APPS_DOCS_URL)) return url; const anchor = url.includes("#") ? `#${url.split("#").pop()}` : ""; return `${chatAppsDocsUrl.replace(/\/$/, "")}${anchor}`; } - -export const CHANNEL_PRESENTATION: Record = { - websocket: { - displayName: "WebSocket", - description: "Use nanobot from the local browser workbench.", - requirements: "Local gateway, WebSocket token", - initials: "WS", - color: "#111827", - icon: Network, - setup: { - mode: "webui", - docsUrl: chatAppGuideUrl("websocket"), - docsLabel: "Open WebSocket setup", - tryIt: "Open the WebUI and send a short message.", - summary: "WebSocket is required by the browser workbench and is prepared by the nanobot webui command.", - steps: [ - "Start the workbench with nanobot webui so the local gateway and WebSocket channel are enabled together.", - "Keep this channel enabled while using the WebUI.", - "Change host, port, or token only from config.json when you need a custom local setup.", - ], - }, - }, - telegram: { - displayName: "Telegram", - description: "Chat with nanobot from Telegram chats.", - requirements: "Bot token, allowed users, gateway", - initials: "TG", - color: "#229ED9", - logoUrl: "https://telegram.org/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("telegram"), - docsLabel: "Open Telegram setup", - officialUrl: TELEGRAM_BOTFATHER_URL, - officialLabel: "Open BotFather", - tryIt: "Send /start or a short DM to your Telegram bot.", - summary: "Enable turns on Telegram support. Telegram still needs a BotFather token before messages can flow.", - steps: [ - "Create a bot with BotFather and copy the bot token.", - "Add the token under channels.telegram.token; optionally restrict allowFrom and groupPolicy.", - "Save and enable Telegram, then send the bot a direct message or mention it in a group.", - ], - fields: [ - { - key: "channels.telegram.token", - label: "Bot token", - placeholder: "123456:ABC...", - secret: true, - help: "Create it with BotFather.", - }, - { - key: "channels.telegram.allowFrom", - label: "Allowed users", - placeholder: "* or Telegram user IDs", - optional: true, - help: "Leave empty to use pairing codes.", - }, - { - key: "channels.telegram.groupPolicy", - label: "Group behavior", - defaultValue: "mention", - options: GROUP_BEHAVIOR_OPTIONS, - optional: true, - }, - ], - }, - }, - feishu: { - displayName: "Feishu", - description: "Use nanobot from Feishu chats and groups.", - requirements: "Feishu app credentials, event subscription, gateway", - initials: "FS", - color: "#3370FF", - logoUrl: "https://www.feishu.cn/favicon.ico", - setup: { - mode: "connect", - primaryActionLabel: "Connect with Feishu", - command: "nanobot channels login feishu", - docsUrl: chatAppGuideUrl("feishu"), - docsLabel: "Open Feishu setup", - officialUrl: FEISHU_OPEN_PLATFORM_URL, - 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.", - ], - manualFields: [ - { - key: "channels.feishu.appId", - label: "App ID", - placeholder: "cli_xxx", - }, - { - key: "channels.feishu.appSecret", - label: "App Secret", - placeholder: "Leave blank to keep current secret", - secret: true, - help: "Paste a new App Secret only when rotating credentials.", - }, - { - key: "channels.feishu.domain", - label: "Region", - defaultValue: "feishu", - options: FEISHU_REGION_OPTIONS, - optional: true, - }, - { - key: "channels.feishu.groupPolicy", - label: "Group behavior", - defaultValue: "mention", - options: GROUP_BEHAVIOR_OPTIONS, - optional: true, - }, - { - key: "channels.feishu.allowFrom", - label: "Allowed users", - placeholder: "User IDs, comma separated", - optional: true, - }, - ], - }, - }, - slack: { - displayName: "Slack", - description: "Use nanobot from Slack workspaces.", - requirements: "Slack app token, bot token, workspace install", - initials: "SL", - color: "#4A154B", - logoUrl: "https://slack.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("slack"), - docsLabel: "Open Slack setup", - officialUrl: SLACK_APPS_URL, - officialLabel: "Open Slack apps", - tryIt: "Mention the Slack app or send it a direct message.", - actions: [ - { - id: "slack-manifest", - label: "Copy manifest", - copyText: SLACK_SOCKET_MODE_MANIFEST, - logoUrl: "https://slack.com/favicon.ico", - }, - ], - summary: "Slack uses Socket Mode by default, so it needs both app-level and bot-level tokens.", - steps: [ - "Create a Slack app, enable Socket Mode, and install it into the workspace.", - "Add the app token and bot token under channels.slack.", - "Save and enable Slack, then mention the app or send it a direct message.", - ], - fields: [ - { - key: "channels.slack.appToken", - label: "App token", - placeholder: "xapp-...", - secret: true, - help: "Create this from Slack Socket Mode.", - }, - { - key: "channels.slack.botToken", - label: "Bot token", - placeholder: "xoxb-...", - secret: true, - help: "Use the bot token after installing the Slack app.", - }, - { - key: "channels.slack.groupPolicy", - label: "Group behavior", - defaultValue: "mention", - options: GROUP_BEHAVIOR_ALLOWLIST_OPTIONS, - optional: true, - }, - ], - }, - }, - discord: { - displayName: "Discord", - description: "Use nanobot from Discord servers and DMs.", - requirements: "Discord bot token, permissions, gateway", - initials: "DC", - color: "#5865F2", - logoUrl: "https://discord.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("discord"), - docsLabel: "Open Discord setup", - officialUrl: DISCORD_DEVELOPER_URL, - 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 an application and bot in the Discord Developer Portal, then copy the bot token.", - "Invite the bot to your server with message read/send and slash command permissions.", - "Add the token under channels.discord.token; optionally restrict allowFrom and allowChannels.", - "Save and enable Discord, then mention the bot or use its slash command.", - ], - fields: [ - { - key: "channels.discord.token", - label: "Bot token", - placeholder: "Discord bot token", - secret: true, - help: "Create it from the Bot page in Discord Developer Portal.", - }, - { - key: "channels.discord.allowChannels", - label: "Allowed channels", - placeholder: "Channel IDs, comma separated", - optional: true, - help: "Leave empty to allow any channel the bot can read.", - }, - { - key: "channels.discord.groupPolicy", - label: "Group behavior", - defaultValue: "mention", - options: GROUP_BEHAVIOR_OPTIONS, - optional: true, - }, - ], - }, - }, - email: { - displayName: "Email", - description: "Let nanobot receive and answer email messages.", - requirements: "IMAP inbox, SMTP sender, app password, explicit consent", - initials: "EM", - color: "#64748B", - logoUrl: "https://gmail.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("email"), - docsLabel: "Open Email setup", - officialUrl: GMAIL_APP_PASSWORDS_URL, - officialLabel: "Open app password guide", - tryIt: "Send a test email to the connected mailbox.", - presets: EMAIL_PROVIDER_PRESETS, - summary: - "Email is IMAP polling plus SMTP replies. Use a dedicated mailbox when possible, and grant explicit consent before nanobot reads mail.", - steps: [ - "Create or choose the mailbox nanobot will own, enable IMAP, and create an app password when the provider requires one.", - "Fill IMAP settings for receiving unread mail, then SMTP settings for sending replies.", - "Set consentGranted to true only after confirming this mailbox may be processed by nanobot.", - "Save and enable Email, then send a test message to the mailbox.", - ], - fields: [ - { - key: "channels.email.consentGranted", - label: "Consent granted", - defaultValue: "false", - options: CONSENT_OPTIONS, - help: "Required safety switch. Leave false until this bot mailbox is intentionally connected.", - }, - { - key: "channels.email.imapHost", - label: "IMAP host", - placeholder: "imap.gmail.com", - }, - { - key: "channels.email.imapUsername", - label: "IMAP username", - placeholder: "bot@example.com", - }, - { - key: "channels.email.imapPassword", - label: "IMAP password", - placeholder: "App password", - secret: true, - help: "Use an app password when your mail provider requires one.", - }, - { - key: "channels.email.smtpHost", - label: "SMTP host", - placeholder: "smtp.gmail.com", - }, - { - key: "channels.email.smtpUsername", - label: "SMTP username", - placeholder: "bot@example.com", - }, - { - key: "channels.email.smtpPassword", - label: "SMTP password", - placeholder: "App password", - secret: true, - help: "Usually the same app password used for IMAP.", - }, - { - key: "channels.email.imapPort", - label: "IMAP port", - placeholder: "993", - inputType: "number", - optional: true, - }, - { - key: "channels.email.smtpPort", - label: "SMTP port", - placeholder: "587", - inputType: "number", - optional: true, - }, - { - key: "channels.email.fromAddress", - label: "From address", - placeholder: "bot@example.com", - optional: true, - }, - { - key: "channels.email.pollIntervalSeconds", - label: "Poll interval", - placeholder: "30", - inputType: "number", - optional: true, - }, - { - key: "channels.email.allowFrom", - label: "Allowed senders", - placeholder: "Email addresses, comma separated", - optional: true, - help: "Leave empty to require pairing before a sender can use email.", - }, - { - key: "channels.email.verifyDkim", - label: "Verify DKIM", - defaultValue: "true", - options: BOOLEAN_OPTIONS, - optional: true, - }, - { - key: "channels.email.verifySpf", - label: "Verify SPF", - defaultValue: "true", - options: BOOLEAN_OPTIONS, - optional: true, - }, - ], - }, - }, - matrix: { - displayName: "Matrix", - description: "Use nanobot from Matrix rooms.", - requirements: "Homeserver, account token, room access", - initials: "MX", - color: "#0DBD8B", - logoUrl: "https://matrix.org/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("matrix"), - docsLabel: "Open Matrix setup", - officialUrl: MATRIX_CLIENTS_URL, - officialLabel: "Open Matrix clients", - tryIt: "Invite the Matrix account into a room and send a test message.", - summary: "Matrix needs a homeserver account and either password login or an access token.", - steps: [ - "Create or choose a Matrix account for nanobot.", - "Add homeserver and login credentials under channels.matrix.", - "Invite the account into the rooms nanobot should read, then restart nanobot.", - ], - fields: [ - { - key: "channels.matrix.homeserver", - label: "Homeserver", - placeholder: "https://matrix.org", - }, - { - key: "channels.matrix.userId", - label: "User ID", - placeholder: "@nanobot:matrix.org", - }, - { - key: "channels.matrix.password", - label: "Password", - placeholder: "••••••", - secret: true, - optional: true, - help: "Use either password login or access token login.", - }, - { - key: "channels.matrix.accessToken", - label: "Access token", - placeholder: "Optional token login", - secret: true, - optional: true, - help: "Preferred when your Matrix client exposes an access token.", - }, - { - key: "channels.matrix.deviceId", - label: "Device ID", - placeholder: "Required with an access token", - optional: true, - help: "Copy the device ID associated with the access token. Password login does not need it.", - }, - { - key: "channels.matrix.groupPolicy", - label: "Group behavior", - defaultValue: "open", - options: GROUP_BEHAVIOR_ALLOWLIST_OPTIONS, - optional: true, - }, - ], - }, - }, - mattermost: { - displayName: "Mattermost", - description: "Use nanobot from Mattermost channels and DMs.", - requirements: "Mattermost server URL, bot token, channel access", - initials: "MM", - color: "#1C58D9", - logoUrl: "https://mattermost.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("mattermost"), - docsLabel: "Open Mattermost setup", - officialUrl: MATTERMOST_BOT_DOCS_URL, - officialLabel: "Open Mattermost bot guide", - tryIt: "Mention the bot in a Mattermost channel or send it a direct message.", - summary: - "Mattermost connects with a bot account token and listens through the Mattermost WebSocket API.", - steps: [ - "Create or choose a Mattermost bot account and copy its token.", - "Add the Mattermost server URL and bot token.", - "Invite the bot to the channels it should read.", - "Save and enable Mattermost, then mention the bot or send a direct message.", - ], - fields: [ - { - key: "channels.mattermost.serverUrl", - label: "Server URL", - placeholder: "https://mattermost.example.com", - help: "Use the base URL of your Mattermost workspace.", - }, - { - key: "channels.mattermost.token", - label: "Bot token", - placeholder: "Mattermost bot token", - secret: true, - help: "Create this from a Mattermost bot account.", - }, - { - key: "channels.mattermost.teamId", - label: "Team ID", - placeholder: "Optional team ID", - optional: true, - }, - { - key: "channels.mattermost.groupPolicy", - label: "Group behavior", - defaultValue: "mention", - options: GROUP_BEHAVIOR_ALLOWLIST_OPTIONS, - optional: true, - }, - ], - }, - }, - whatsapp: { - displayName: "WhatsApp", - description: "Use nanobot from WhatsApp conversations.", - requirements: "WhatsApp connection setup and gateway", - initials: "WA", - color: "#25D366", - logoUrl: "https://www.whatsapp.com/favicon.ico", - setup: { - mode: "connect", - primaryActionLabel: "Connect WhatsApp", - command: "nanobot channels login whatsapp", - docsUrl: chatAppGuideUrl("whatsapp"), - docsLabel: "Open WhatsApp setup", - tryIt: "After terminal login finishes, send a WhatsApp DM to the connected account.", - summary: "WhatsApp is connected by scanning a QR code from the account that should run the bot.", - steps: [ - "Start the WhatsApp login flow.", - "Scan the QR code with WhatsApp on your phone.", - "Return here after login, enable WhatsApp, then send a direct test message.", - ], - manualFields: [ - { - key: "channels.whatsapp.allowFrom", - label: "Allowed contacts", - placeholder: "Phone numbers or WhatsApp IDs", - optional: true, - }, - { - key: "channels.whatsapp.groupPolicy", - label: "Group behavior", - defaultValue: "open", - options: GROUP_BEHAVIOR_OPTIONS, - optional: true, - }, - ], - }, - }, - dingtalk: { - displayName: "DingTalk", - description: "Use nanobot from DingTalk groups.", - requirements: "DingTalk app credentials and gateway", - initials: "DT", - color: "#1677FF", - logoUrl: "https://www.dingtalk.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("dingtalk"), - docsLabel: "Open DingTalk setup", - officialUrl: DINGTALK_OPEN_PLATFORM_URL, - 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: [ - { - key: "channels.dingtalk.clientId", - label: "Client ID", - placeholder: "DingTalk client ID", - help: "Copy it from DingTalk app credentials.", - }, - { - key: "channels.dingtalk.clientSecret", - label: "Client Secret", - placeholder: "••••••", - secret: true, - help: "Copy it from the same DingTalk app credentials page.", - }, - { - key: "channels.dingtalk.allowFrom", - label: "Allowed users", - placeholder: "User IDs, comma separated", - optional: true, - }, - ], - }, - }, - wecom: { - displayName: "WeCom", - description: "Use nanobot from WeCom work chats.", - requirements: "WeCom app credentials and callback settings", - initials: "WC", - color: "#2F7DFF", - logoUrl: "https://work.weixin.qq.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("wecom"), - docsLabel: "Open WeCom setup", - officialUrl: WECOM_DEVELOPER_URL, - officialLabel: "Open WeCom console", - tryIt: "Send a test message to the WeCom bot.", - summary: "WeCom needs an AI bot ID and secret from the WeCom admin console.", - steps: [ - "Create or choose a WeCom AI Bot.", - "Add Bot ID and Secret.", - "Save and enable WeCom, then send a test message.", - ], - fields: [ - { - key: "channels.wecom.botId", - label: "Bot ID", - placeholder: "WeCom bot ID", - help: "Copy it from the WeCom AI Bot API mode page.", - }, - { - key: "channels.wecom.secret", - label: "Secret", - placeholder: "••••••", - secret: true, - help: "Keep the WeCom bot secret private.", - }, - { - key: "channels.wecom.allowFrom", - label: "Allowed users", - placeholder: "User IDs, comma separated", - optional: true, - }, - ], - }, - }, - weixin: { - displayName: "WeChat", - description: "Use nanobot from WeChat conversations.", - requirements: "WeChat channel setup and gateway", - initials: "WX", - color: "#07C160", - logoUrl: "https://weixin.qq.com/favicon.ico", - setup: { - mode: "connect", - primaryActionLabel: "Connect WeChat", - command: "nanobot channels login weixin", - docsUrl: chatAppGuideUrl("wechat"), - docsLabel: "Open WeChat setup", - tryIt: "After the QR login finishes, send a WeChat DM to the connected account.", - summary: "WeChat signs in with a QR code and saves the account state locally.", - steps: [ - "Click Connect and scan the QR code with WeChat.", - "Keep the local gateway running while WeChat receives messages.", - "Send a direct test message to confirm the account is connected.", - ], - manualFields: [ - { - key: "channels.weixin.allowFrom", - label: "Allowed users", - placeholder: "User IDs, comma separated", - optional: true, - }, - { - key: "channels.weixin.token", - label: "Token", - placeholder: "Saved by QR login", - secret: true, - optional: true, - }, - ], - }, - }, - qq: { - displayName: "QQ", - description: "Use nanobot from QQ chats.", - requirements: "QQ bot credentials and gateway", - initials: "QQ", - color: "#12B7F5", - logoUrl: "https://im.qq.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("qq"), - docsLabel: "Open QQ setup", - officialUrl: QQ_OPEN_PLATFORM_URL, - officialLabel: "Open QQ bot console", - tryIt: "Send a direct or group test message from QQ.", - summary: "QQ uses the official bot credentials and a long WebSocket connection.", - steps: [ - "Create or choose a QQ bot application and copy its App ID and Secret.", - "Add appId and secret under channels.qq.", - "Save and enable QQ, then send a direct or group test message.", - ], - fields: [ - { - key: "channels.qq.appId", - label: "App ID", - placeholder: "QQ bot app ID", - help: "Copy it from QQ Open Platform.", - }, - { - key: "channels.qq.secret", - label: "Secret", - placeholder: "••••••", - secret: true, - help: "Save this before leaving the QQ credentials page.", - }, - { - key: "channels.qq.allowFrom", - label: "Allowed users", - placeholder: "Open IDs, comma separated", - optional: true, - }, - { - key: "channels.qq.msgFormat", - label: "Message format", - defaultValue: "plain", - options: QQ_MESSAGE_FORMAT_OPTIONS, - optional: true, - }, - ], - }, - }, - signal: { - displayName: "Signal", - description: "Use nanobot from Signal messages.", - requirements: "signal-cli HTTP daemon, phone number, allowlist", - initials: "SG", - color: "#3A76F0", - logoUrl: "https://signal.org/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("signal"), - docsLabel: "Open Signal setup", - officialUrl: SIGNAL_CLI_URL, - officialLabel: "Open signal-cli guide", - tryIt: "Send a Signal DM to the linked phone number.", - summary: - "Signal connects through a local signal-cli HTTP daemon. Run the daemon first, then point nanobot at it.", - steps: [ - "Register or link the Signal account in signal-cli.", - "Start signal-cli in HTTP daemon mode for the same phone number.", - "Set phoneNumber plus daemon host and port under channels.signal.", - "Save and enable Signal, then send a direct test message.", - ], - fields: [ - { - key: "channels.signal.phoneNumber", - label: "Phone number", - placeholder: "+1234567890", - help: "Use the Signal number registered with signal-cli.", - }, - { - key: "channels.signal.daemonHost", - label: "Daemon host", - placeholder: "localhost", - optional: true, - }, - { - key: "channels.signal.daemonPort", - label: "Daemon port", - placeholder: "8080", - inputType: "number", - optional: true, - }, - { - key: "channels.signal.dm.allowFrom", - label: "Allowed DMs", - placeholder: "Phone numbers or UUIDs", - optional: true, - }, - { - key: "channels.signal.group.allowFrom", - label: "Allowed groups", - placeholder: "Group IDs", - optional: true, - }, - ], - }, - }, - msteams: { - displayName: "Microsoft Teams", - description: "Use nanobot from Microsoft Teams chats.", - requirements: "Azure bot app credentials, public callback endpoint", - initials: "MS", - color: "#6264A7", - logoUrl: "https://www.microsoft.com/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("msteams"), - docsLabel: "Open Teams setup", - officialUrl: TEAMS_DEVELOPER_URL, - officialLabel: "Open Teams developer portal", - tryIt: "Install the Teams app and send a test message.", - summary: - "Teams receives messages through the Bot Framework callback URL. It needs a reachable HTTPS endpoint in production.", - steps: [ - "Create an Azure Bot / Teams app and copy the Microsoft App ID and client secret.", - "Set the bot messaging endpoint to the nanobot Teams callback path.", - "Add appId and appPassword under channels.msteams.", - "Save and enable Teams, then install the app and send a test message.", - ], - fields: [ - { - key: "channels.msteams.appId", - label: "App ID", - placeholder: "Microsoft App ID", - help: "Copy it from the Azure Bot or Teams app registration.", - }, - { - key: "channels.msteams.appPassword", - label: "Client secret", - placeholder: "••••••", - secret: true, - help: "Create a client secret for the Microsoft app.", - }, - { - key: "channels.msteams.tenantId", - label: "Tenant ID", - placeholder: "Optional tenant ID", - optional: true, - }, - { - key: "channels.msteams.path", - label: "Callback path", - placeholder: "/api/messages", - optional: true, - }, - { - key: "channels.msteams.allowFrom", - label: "Allowed users", - placeholder: "Teams user IDs, comma separated", - optional: true, - }, - ], - }, - }, - napcat: { - displayName: "NapCat", - description: "Connect nanobot through a NapCat gateway.", - requirements: "NapCat WebSocket endpoint, optional access token", - initials: "NC", - color: "#F97316", - logoUrl: "https://napneko.github.io/favicon.ico", - setup: { - mode: "credentials", - docsUrl: chatAppGuideUrl("napcat"), - docsLabel: "Open NapCat setup", - officialUrl: NAPCAT_DOCS_URL, - officialLabel: "Open NapCat docs", - tryIt: "Send a QQ test message through NapCat.", - summary: "NapCat connects nanobot to QQ through a local or remote OneBot WebSocket endpoint.", - steps: [ - "Start NapCat and enable its OneBot WebSocket server.", - "Set wsUrl to the NapCat WebSocket endpoint; add accessToken if NapCat requires one.", - "Save and enable NapCat, then send a QQ test message.", - ], - fields: [ - { - key: "channels.napcat.wsUrl", - label: "WebSocket URL", - placeholder: "ws://127.0.0.1:3001", - help: "Use the Forward WebSocket URL from NapCat.", - }, - { - key: "channels.napcat.accessToken", - label: "Access token", - placeholder: "Optional token", - secret: true, - optional: true, - }, - { - key: "channels.napcat.groupPolicy", - label: "Group behavior", - defaultValue: "mention", - options: GROUP_BEHAVIOR_OPTIONS, - optional: true, - }, - { - key: "channels.napcat.allowFrom", - label: "Allowed users", - placeholder: "QQ IDs, comma separated", - optional: true, - }, - ], - }, - }, -}; diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 2978189c..e82b26da 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -1,6 +1,11 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; +import { + channelLocaleNamespaces, + channelLocaleResources, +} from "@/channel-plugins/locale-registry"; + import { applyDocumentLocale, defaultLocale, @@ -24,16 +29,16 @@ import viCommon from "./locales/vi/common.json"; import idCommon from "./locales/id/common.json"; export const resources = { - en: { common: enCommon }, - "zh-CN": { common: zhCNCommon }, - "zh-TW": { common: zhTWCommon }, - fr: { common: frCommon }, - ja: { common: jaCommon }, - ko: { common: koCommon }, - es: { common: esCommon }, - "pt-BR": { common: ptBRCommon }, - vi: { common: viCommon }, - id: { common: idCommon }, + en: { common: enCommon, ...channelLocaleResources("en") }, + "zh-CN": { common: zhCNCommon, ...channelLocaleResources("zh-CN") }, + "zh-TW": { common: zhTWCommon, ...channelLocaleResources("zh-TW") }, + fr: { common: frCommon, ...channelLocaleResources("fr") }, + ja: { common: jaCommon, ...channelLocaleResources("ja") }, + ko: { common: koCommon, ...channelLocaleResources("ko") }, + es: { common: esCommon, ...channelLocaleResources("es") }, + "pt-BR": { common: ptBRCommon, ...channelLocaleResources("pt-BR") }, + vi: { common: viCommon, ...channelLocaleResources("vi") }, + id: { common: idCommon, ...channelLocaleResources("id") }, } as const; export function currentLocale(): SupportedLocale { @@ -52,7 +57,7 @@ if (!i18n.isInitialized) { lng: resolveInitialLocale(), fallbackLng: fallbackLocale, defaultNS: "common", - ns: ["common"], + ns: ["common", ...channelLocaleNamespaces()], interpolation: { escapeValue: false, }, diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 2257f548..dec2ea21 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -510,7 +510,7 @@ }, "channels": { "description": "Connect chat apps, email, and WebUI to nanobot.", - "caption": "{{enabled}} enabled · {{total}} channels", + "caption": "{{enabled}} running · {{total}} channels", "searchPlaceholder": "Search channels", "backToChannels": "All channels", "catalog": "Channels", @@ -527,13 +527,51 @@ "needsConfig": "Needs setup", "connect": "Connect", "reconnect": "Reconnect", - "feishuQrAlt": "Feishu connection QR code", - "feishuScanTitle": "Scan with Feishu", - "feishuScanDescription": "Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.", - "feishuWaiting": "Waiting for authorization...", - "feishuConnected": "Feishu is connected.", - "feishuConnectStopped": "Connection stopped.", - "feishuConnecting": "Connecting..." + "advanced": "Advanced", + "checkAndEnable": "Check and enable", + "checkConnection": "Check connection", + "checkedAndEnabled": "Checked and enabled.", + "checking": "Checking...", + "checkOnly": "Check only", + "commandCopied": "Command copied.", + "commandCopyFailed": "Could not copy command.", + "configuredInstances": "{{count}} instances configured", + "connectPreview": "The in-browser connect flow is next. For now, run the command below.", + "copyCommand": "Copy command", + "filterAll": "All", + "filterOff": "Not running", + "filterOn": "Running", + "helperCopied": "{{name}} copied.", + "helperCopyFailed": "Could not copy {{name}}.", + "hideSecret": "Hide secret", + "instanceConfigured": "Configured", + "instanceNeedsSetup": "Needs setup", + "runtimeFailed": "Failed", + "runtimeStarting": "Starting", + "runtimeStopped": "Not running", + "managedByWebui": "Managed by WebUI", + "officialGuide": "Official guide", + "optional": "Optional", + "providerPreset": "Provider", + "requiredSetup": "Required setup", + "savedSecret": "Saved", + "savedSecretPlaceholder": "Saved secret", + "savedSettings": "Saved settings.", + "saveSettings": "Save settings", + "selectChannel": "View {{name}} settings", + "setupSteps": "Next steps", + "showSecret": "Show secret", + "toggleChannel": "{{name}} channel", + "toggleInstance": "{{name}} instance", + "tryIt": "Try it", + "validationFailed": "Check the required setup before enabling.", + "validation": { + "connected": "Connected", + "configured": "Configured manually", + "needs_setup": "Needs setup", + "invalid": "Invalid", + "unsupported": "Manual setup" + } }, "nanobotFeatures": { "enabled": "Enabled", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index f739b47a..89defe57 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -514,13 +514,51 @@ "needsConfig": "Necesita configuración", "connect": "Conectar", "reconnect": "Reconectar", - "feishuQrAlt": "Código QR de conexión de Feishu", - "feishuScanTitle": "Escanea con Feishu", - "feishuScanDescription": "Usa Feishu o Lark en tu teléfono para escanear este código. nanobot terminará la configuración automáticamente después de la autorización.", - "feishuWaiting": "Esperando autorización...", - "feishuConnected": "Feishu está conectado.", - "feishuConnectStopped": "Conexión detenida.", - "feishuConnecting": "Conectando..." + "advanced": "Avanzado", + "checkAndEnable": "Comprobar y activar", + "checkConnection": "Comprobar conexión", + "checkedAndEnabled": "Comprobado y activado.", + "checking": "Comprobando...", + "checkOnly": "Solo comprobar", + "commandCopied": "Comando copiado.", + "commandCopyFailed": "No se pudo copiar el comando.", + "configuredInstances": "{{count}} instancias configuradas", + "connectPreview": "El siguiente paso es la conexión en el navegador. Por ahora, ejecuta el comando siguiente.", + "copyCommand": "Copiar comando", + "filterAll": "Todos", + "filterOff": "Desactivados", + "filterOn": "Activados", + "helperCopied": "{{name}} copiado.", + "helperCopyFailed": "No se pudo copiar {{name}}.", + "hideSecret": "Ocultar secreto", + "instanceConfigured": "Configurada", + "instanceNeedsSetup": "Requiere configuración", + "runtimeFailed": "Error al iniciar", + "runtimeStarting": "Iniciando", + "runtimeStopped": "No está en ejecución", + "managedByWebui": "Gestionado por WebUI", + "officialGuide": "Guía oficial", + "optional": "Opcional", + "providerPreset": "Proveedor", + "requiredSetup": "Configuración requerida", + "savedSecret": "Guardado", + "savedSecretPlaceholder": "Secreto guardado", + "savedSettings": "Configuración guardada.", + "saveSettings": "Guardar configuración", + "selectChannel": "Ver la configuración de {{name}}", + "setupSteps": "Siguientes pasos", + "showSecret": "Mostrar secreto", + "toggleChannel": "Canal {{name}}", + "toggleInstance": "Instancia {{name}}", + "tryIt": "Pruébalo", + "validationFailed": "Comprueba la configuración requerida antes de activar.", + "validation": { + "connected": "Conectado", + "configured": "Configurado manualmente", + "needs_setup": "Requiere configuración", + "invalid": "No válido", + "unsupported": "Configuración manual" + } }, "nanobotFeatures": { "enabled": "Activado", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 96fecd57..236b95de 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -513,13 +513,51 @@ "needsConfig": "Configuration requise", "connect": "Connecter", "reconnect": "Reconnecter", - "feishuQrAlt": "QR code de connexion Feishu", - "feishuScanTitle": "Scanner avec Feishu", - "feishuScanDescription": "Utilisez Feishu ou Lark sur votre téléphone pour scanner ce code. nanobot terminera la configuration automatiquement après l'autorisation.", - "feishuWaiting": "En attente d'autorisation...", - "feishuConnected": "Feishu est connecté.", - "feishuConnectStopped": "Connexion arrêtée.", - "feishuConnecting": "Connexion..." + "advanced": "Avancé", + "checkAndEnable": "Vérifier et activer", + "checkConnection": "Vérifier la connexion", + "checkedAndEnabled": "Vérifié et activé.", + "checking": "Vérification...", + "checkOnly": "Vérifier uniquement", + "commandCopied": "Commande copiée.", + "commandCopyFailed": "Impossible de copier la commande.", + "configuredInstances": "{{count}} instances configurées", + "connectPreview": "La connexion dans le navigateur est la prochaine étape. Pour l'instant, exécutez la commande ci-dessous.", + "copyCommand": "Copier la commande", + "filterAll": "Tous", + "filterOff": "Désactivés", + "filterOn": "Activés", + "helperCopied": "{{name}} copié.", + "helperCopyFailed": "Impossible de copier {{name}}.", + "hideSecret": "Masquer le secret", + "instanceConfigured": "Configurée", + "instanceNeedsSetup": "Configuration requise", + "runtimeFailed": "Échec du démarrage", + "runtimeStarting": "Démarrage", + "runtimeStopped": "À l'arrêt", + "managedByWebui": "Géré par la WebUI", + "officialGuide": "Guide officiel", + "optional": "Facultatif", + "providerPreset": "Fournisseur", + "requiredSetup": "Configuration requise", + "savedSecret": "Enregistré", + "savedSecretPlaceholder": "Secret enregistré", + "savedSettings": "Paramètres enregistrés.", + "saveSettings": "Enregistrer les paramètres", + "selectChannel": "Afficher les paramètres de {{name}}", + "setupSteps": "Étapes suivantes", + "showSecret": "Afficher le secret", + "toggleChannel": "Canal {{name}}", + "toggleInstance": "Instance {{name}}", + "tryIt": "Essayer", + "validationFailed": "Vérifiez la configuration requise avant l'activation.", + "validation": { + "connected": "Connecté", + "configured": "Configuré manuellement", + "needs_setup": "Configuration requise", + "invalid": "Non valide", + "unsupported": "Configuration manuelle" + } }, "nanobotFeatures": { "enabled": "Activé", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 1667c3e7..aedb7866 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -513,13 +513,51 @@ "needsConfig": "Perlu konfigurasi", "connect": "Hubungkan", "reconnect": "Hubungkan ulang", - "feishuQrAlt": "Kode QR koneksi Feishu", - "feishuScanTitle": "Pindai dengan Feishu", - "feishuScanDescription": "Gunakan Feishu atau Lark di ponsel untuk memindai kode ini. nanobot akan menyelesaikan setup secara otomatis setelah otorisasi.", - "feishuWaiting": "Menunggu otorisasi...", - "feishuConnected": "Feishu sudah terhubung.", - "feishuConnectStopped": "Koneksi dihentikan.", - "feishuConnecting": "Menghubungkan..." + "advanced": "Lanjutan", + "checkAndEnable": "Periksa dan aktifkan", + "checkConnection": "Periksa koneksi", + "checkedAndEnabled": "Sudah diperiksa dan diaktifkan.", + "checking": "Memeriksa...", + "checkOnly": "Periksa saja", + "commandCopied": "Perintah disalin.", + "commandCopyFailed": "Tidak dapat menyalin perintah.", + "configuredInstances": "{{count}} instans dikonfigurasi", + "connectPreview": "Langkah berikutnya adalah koneksi di browser. Untuk saat ini, jalankan perintah di bawah.", + "copyCommand": "Salin perintah", + "filterAll": "Semua", + "filterOff": "Nonaktif", + "filterOn": "Aktif", + "helperCopied": "{{name}} disalin.", + "helperCopyFailed": "Tidak dapat menyalin {{name}}.", + "hideSecret": "Sembunyikan rahasia", + "instanceConfigured": "Dikonfigurasi", + "instanceNeedsSetup": "Perlu penyiapan", + "runtimeFailed": "Gagal dimulai", + "runtimeStarting": "Memulai", + "runtimeStopped": "Tidak berjalan", + "managedByWebui": "Dikelola oleh WebUI", + "officialGuide": "Panduan resmi", + "optional": "Opsional", + "providerPreset": "Penyedia", + "requiredSetup": "Penyiapan wajib", + "savedSecret": "Tersimpan", + "savedSecretPlaceholder": "Rahasia tersimpan", + "savedSettings": "Pengaturan disimpan.", + "saveSettings": "Simpan pengaturan", + "selectChannel": "Lihat pengaturan {{name}}", + "setupSteps": "Langkah berikutnya", + "showSecret": "Tampilkan rahasia", + "toggleChannel": "Kanal {{name}}", + "toggleInstance": "Instans {{name}}", + "tryIt": "Coba", + "validationFailed": "Periksa penyiapan wajib sebelum mengaktifkan.", + "validation": { + "connected": "Terhubung", + "configured": "Dikonfigurasi manual", + "needs_setup": "Perlu penyiapan", + "invalid": "Tidak valid", + "unsupported": "Penyiapan manual" + } }, "nanobotFeatures": { "enabled": "Aktif", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index bc4239da..231f5ff4 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -513,13 +513,51 @@ "needsConfig": "設定が必要", "connect": "接続", "reconnect": "再接続", - "feishuQrAlt": "Feishu 接続 QR コード", - "feishuScanTitle": "Feishu でスキャン", - "feishuScanDescription": "スマートフォンの Feishu または Lark でこのコードをスキャンしてください。認可後、nanobot が自動で設定を完了します。", - "feishuWaiting": "認可を待っています...", - "feishuConnected": "Feishu に接続しました。", - "feishuConnectStopped": "接続を停止しました。", - "feishuConnecting": "接続中..." + "advanced": "詳細設定", + "checkAndEnable": "確認して有効化", + "checkConnection": "接続を確認", + "checkedAndEnabled": "確認して有効化しました。", + "checking": "確認中...", + "checkOnly": "確認のみ", + "commandCopied": "コマンドをコピーしました。", + "commandCopyFailed": "コマンドをコピーできませんでした。", + "configuredInstances": "{{count}} 個のインスタンスを設定済み", + "connectPreview": "次の手順でブラウザー内接続を行います。今は下のコマンドを実行してください。", + "copyCommand": "コマンドをコピー", + "filterAll": "すべて", + "filterOff": "オフ", + "filterOn": "オン", + "helperCopied": "{{name}} をコピーしました。", + "helperCopyFailed": "{{name}} をコピーできませんでした。", + "hideSecret": "シークレットを隠す", + "instanceConfigured": "設定済み", + "instanceNeedsSetup": "設定が必要", + "runtimeFailed": "起動失敗", + "runtimeStarting": "起動中", + "runtimeStopped": "未実行", + "managedByWebui": "WebUI で管理", + "officialGuide": "公式ガイド", + "optional": "任意", + "providerPreset": "プロバイダー", + "requiredSetup": "必要な設定", + "savedSecret": "保存済み", + "savedSecretPlaceholder": "保存済みのシークレット", + "savedSettings": "設定を保存しました。", + "saveSettings": "設定を保存", + "selectChannel": "{{name}} の設定を表示", + "setupSteps": "次の手順", + "showSecret": "シークレットを表示", + "toggleChannel": "{{name}} チャンネル", + "toggleInstance": "{{name}} インスタンス", + "tryIt": "試してみる", + "validationFailed": "有効化する前に必要な設定を確認してください。", + "validation": { + "connected": "接続済み", + "configured": "手動設定済み", + "needs_setup": "設定が必要", + "invalid": "無効", + "unsupported": "手動設定" + } }, "nanobotFeatures": { "enabled": "有効", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 20914c12..8e21ffe3 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -513,13 +513,51 @@ "needsConfig": "설정 필요", "connect": "연결", "reconnect": "다시 연결", - "feishuQrAlt": "Feishu 연결 QR 코드", - "feishuScanTitle": "Feishu로 스캔", - "feishuScanDescription": "휴대폰의 Feishu 또는 Lark로 이 코드를 스캔하세요. 승인 후 nanobot이 자동으로 설정을 완료합니다.", - "feishuWaiting": "승인을 기다리는 중...", - "feishuConnected": "Feishu가 연결되었습니다.", - "feishuConnectStopped": "연결이 중지되었습니다.", - "feishuConnecting": "연결 중..." + "advanced": "고급", + "checkAndEnable": "확인 후 활성화", + "checkConnection": "연결 확인", + "checkedAndEnabled": "확인 후 활성화했습니다.", + "checking": "확인 중...", + "checkOnly": "확인만", + "commandCopied": "명령을 복사했습니다.", + "commandCopyFailed": "명령을 복사하지 못했습니다.", + "configuredInstances": "인스턴스 {{count}}개 구성됨", + "connectPreview": "다음 단계에서 브라우저 내 연결을 진행합니다. 지금은 아래 명령을 실행하세요.", + "copyCommand": "명령 복사", + "filterAll": "전체", + "filterOff": "꺼짐", + "filterOn": "켜짐", + "helperCopied": "{{name}}을(를) 복사했습니다.", + "helperCopyFailed": "{{name}}을(를) 복사하지 못했습니다.", + "hideSecret": "비밀 값 숨기기", + "instanceConfigured": "구성됨", + "instanceNeedsSetup": "설정 필요", + "runtimeFailed": "시작 실패", + "runtimeStarting": "시작 중", + "runtimeStopped": "실행 중 아님", + "managedByWebui": "WebUI에서 관리", + "officialGuide": "공식 가이드", + "optional": "선택 사항", + "providerPreset": "제공자", + "requiredSetup": "필수 설정", + "savedSecret": "저장됨", + "savedSecretPlaceholder": "저장된 비밀 값", + "savedSettings": "설정을 저장했습니다.", + "saveSettings": "설정 저장", + "selectChannel": "{{name}} 설정 보기", + "setupSteps": "다음 단계", + "showSecret": "비밀 값 표시", + "toggleChannel": "{{name}} 채널", + "toggleInstance": "{{name}} 인스턴스", + "tryIt": "사용해 보기", + "validationFailed": "활성화하기 전에 필수 설정을 확인하세요.", + "validation": { + "connected": "연결됨", + "configured": "수동 구성됨", + "needs_setup": "설정 필요", + "invalid": "잘못됨", + "unsupported": "수동 설정" + } }, "nanobotFeatures": { "enabled": "활성화됨", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index a07bcf70..f2899d01 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -527,13 +527,51 @@ "needsConfig": "Precisa de configuração", "connect": "Conectar", "reconnect": "Reconectar", - "feishuQrAlt": "QR code de conexão do Feishu", - "feishuScanTitle": "Escaneie com o Feishu", - "feishuScanDescription": "Use o Feishu ou o Lark no seu celular para escanear este código. O nanobot concluirá a configuração automaticamente após a autorização.", - "feishuWaiting": "Aguardando autorização...", - "feishuConnected": "Feishu está conectado.", - "feishuConnectStopped": "Conexão interrompida.", - "feishuConnecting": "Conectando..." + "advanced": "Avançado", + "checkAndEnable": "Verificar e ativar", + "checkConnection": "Verificar conexão", + "checkedAndEnabled": "Verificado e ativado.", + "checking": "Verificando...", + "checkOnly": "Apenas verificar", + "commandCopied": "Comando copiado.", + "commandCopyFailed": "Não foi possível copiar o comando.", + "configuredInstances": "{{count}} instâncias configuradas", + "connectPreview": "A próxima etapa é a conexão no navegador. Por enquanto, execute o comando abaixo.", + "copyCommand": "Copiar comando", + "filterAll": "Todos", + "filterOff": "Desativados", + "filterOn": "Ativados", + "helperCopied": "{{name}} copiado.", + "helperCopyFailed": "Não foi possível copiar {{name}}.", + "hideSecret": "Ocultar segredo", + "instanceConfigured": "Configurada", + "instanceNeedsSetup": "Requer configuração", + "runtimeFailed": "Falha ao iniciar", + "runtimeStarting": "Iniciando", + "runtimeStopped": "Não está em execução", + "managedByWebui": "Gerenciado pela WebUI", + "officialGuide": "Guia oficial", + "optional": "Opcional", + "providerPreset": "Provedor", + "requiredSetup": "Configuração obrigatória", + "savedSecret": "Salvo", + "savedSecretPlaceholder": "Segredo salvo", + "savedSettings": "Configurações salvas.", + "saveSettings": "Salvar configurações", + "selectChannel": "Ver configurações de {{name}}", + "setupSteps": "Próximas etapas", + "showSecret": "Mostrar segredo", + "toggleChannel": "Canal {{name}}", + "toggleInstance": "Instância {{name}}", + "tryIt": "Experimente", + "validationFailed": "Verifique a configuração obrigatória antes de ativar.", + "validation": { + "connected": "Conectado", + "configured": "Configurado manualmente", + "needs_setup": "Requer configuração", + "invalid": "Inválido", + "unsupported": "Configuração manual" + } }, "nanobotFeatures": { "enabled": "Habilitado", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 614e827c..152c5b32 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -513,13 +513,51 @@ "needsConfig": "Cần cấu hình", "connect": "Kết nối", "reconnect": "Kết nối lại", - "feishuQrAlt": "Mã QR kết nối Feishu", - "feishuScanTitle": "Quét bằng Feishu", - "feishuScanDescription": "Dùng Feishu hoặc Lark trên điện thoại để quét mã này. nanobot sẽ tự hoàn tất cấu hình sau khi cấp quyền.", - "feishuWaiting": "Đang chờ cấp quyền...", - "feishuConnected": "Feishu đã kết nối.", - "feishuConnectStopped": "Kết nối đã dừng.", - "feishuConnecting": "Đang kết nối..." + "advanced": "Nâng cao", + "checkAndEnable": "Kiểm tra và bật", + "checkConnection": "Kiểm tra kết nối", + "checkedAndEnabled": "Đã kiểm tra và bật.", + "checking": "Đang kiểm tra...", + "checkOnly": "Chỉ kiểm tra", + "commandCopied": "Đã sao chép lệnh.", + "commandCopyFailed": "Không thể sao chép lệnh.", + "configuredInstances": "Đã cấu hình {{count}} phiên bản", + "connectPreview": "Bước tiếp theo là kết nối trong trình duyệt. Hiện tại, hãy chạy lệnh bên dưới.", + "copyCommand": "Sao chép lệnh", + "filterAll": "Tất cả", + "filterOff": "Tắt", + "filterOn": "Bật", + "helperCopied": "Đã sao chép {{name}}.", + "helperCopyFailed": "Không thể sao chép {{name}}.", + "hideSecret": "Ẩn khóa bí mật", + "instanceConfigured": "Đã cấu hình", + "instanceNeedsSetup": "Cần thiết lập", + "runtimeFailed": "Khởi động thất bại", + "runtimeStarting": "Đang khởi động", + "runtimeStopped": "Không chạy", + "managedByWebui": "Do WebUI quản lý", + "officialGuide": "Hướng dẫn chính thức", + "optional": "Tùy chọn", + "providerPreset": "Nhà cung cấp", + "requiredSetup": "Thiết lập bắt buộc", + "savedSecret": "Đã lưu", + "savedSecretPlaceholder": "Khóa bí mật đã lưu", + "savedSettings": "Đã lưu cài đặt.", + "saveSettings": "Lưu cài đặt", + "selectChannel": "Xem cài đặt {{name}}", + "setupSteps": "Các bước tiếp theo", + "showSecret": "Hiện khóa bí mật", + "toggleChannel": "Kênh {{name}}", + "toggleInstance": "Phiên bản {{name}}", + "tryIt": "Dùng thử", + "validationFailed": "Hãy kiểm tra thiết lập bắt buộc trước khi bật.", + "validation": { + "connected": "Đã kết nối", + "configured": "Đã cấu hình thủ công", + "needs_setup": "Cần thiết lập", + "invalid": "Không hợp lệ", + "unsupported": "Thiết lập thủ công" + } }, "nanobotFeatures": { "enabled": "Đã bật", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 122eb366..6331fcbc 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -510,7 +510,7 @@ }, "channels": { "description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。", - "caption": "{{enabled}} 个已启用 · 共 {{total}} 个渠道", + "caption": "{{enabled}} 个运行中 · 共 {{total}} 个渠道", "searchPlaceholder": "搜索渠道", "backToChannels": "所有渠道", "catalog": "渠道", @@ -527,13 +527,51 @@ "needsConfig": "需要配置", "connect": "连接", "reconnect": "重新连接", - "feishuQrAlt": "飞书连接二维码", - "feishuScanTitle": "使用飞书扫码", - "feishuScanDescription": "用手机上的飞书或 Lark 扫描二维码。授权完成后,nanobot 会自动完成配置。", - "feishuWaiting": "正在等待授权...", - "feishuConnected": "飞书已连接。", - "feishuConnectStopped": "连接已停止。", - "feishuConnecting": "正在连接..." + "advanced": "高级", + "checkAndEnable": "检查并启用", + "checkConnection": "检查连接", + "checkedAndEnabled": "已检查并启用。", + "checking": "正在检查...", + "checkOnly": "仅检查", + "commandCopied": "命令已复制。", + "commandCopyFailed": "无法复制命令。", + "configuredInstances": "已配置 {{count}} 个实例", + "connectPreview": "下一步将在浏览器内连接。目前请先运行下方命令。", + "copyCommand": "复制命令", + "filterAll": "全部", + "filterOff": "未运行", + "filterOn": "运行中", + "helperCopied": "已复制 {{name}}。", + "helperCopyFailed": "无法复制 {{name}}。", + "hideSecret": "隐藏密钥", + "instanceConfigured": "已配置", + "instanceNeedsSetup": "需要配置", + "runtimeFailed": "启动失败", + "runtimeStarting": "正在启动", + "runtimeStopped": "未运行", + "managedByWebui": "由 WebUI 管理", + "officialGuide": "官方指南", + "optional": "可选", + "providerPreset": "服务商", + "requiredSetup": "必需配置", + "savedSecret": "已保存", + "savedSecretPlaceholder": "已保存的密钥", + "savedSettings": "设置已保存。", + "saveSettings": "保存设置", + "selectChannel": "查看 {{name}} 设置", + "setupSteps": "后续步骤", + "showSecret": "显示密钥", + "toggleChannel": "{{name}} 渠道", + "toggleInstance": "{{name}} 实例", + "tryIt": "试一试", + "validationFailed": "启用前请检查必需配置。", + "validation": { + "connected": "已连接", + "configured": "手动配置", + "needs_setup": "需要配置", + "invalid": "无效", + "unsupported": "手动配置" + } }, "nanobotFeatures": { "enabled": "已启用", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 1993a9a0..7a563c58 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -513,13 +513,51 @@ "needsConfig": "需要設定", "connect": "連線", "reconnect": "重新連線", - "feishuQrAlt": "飛書連線 QR Code", - "feishuScanTitle": "使用飛書掃描", - "feishuScanDescription": "請使用手機上的飛書或 Lark 掃描此 QR Code。完成授權後,nanobot 會自動完成設定。", - "feishuWaiting": "正在等待授權…", - "feishuConnected": "飛書已連線。", - "feishuConnectStopped": "連線已停止。", - "feishuConnecting": "正在連線…" + "advanced": "進階", + "checkAndEnable": "檢查並啟用", + "checkConnection": "檢查連線", + "checkedAndEnabled": "已檢查並啟用。", + "checking": "正在檢查...", + "checkOnly": "僅檢查", + "commandCopied": "指令已複製。", + "commandCopyFailed": "無法複製指令。", + "configuredInstances": "已設定 {{count}} 個執行個體", + "connectPreview": "下一步將在瀏覽器內連線。目前請先執行下方指令。", + "copyCommand": "複製指令", + "filterAll": "全部", + "filterOff": "關閉", + "filterOn": "開啟", + "helperCopied": "已複製 {{name}}。", + "helperCopyFailed": "無法複製 {{name}}。", + "hideSecret": "隱藏密鑰", + "instanceConfigured": "已設定", + "instanceNeedsSetup": "需要設定", + "runtimeFailed": "啟動失敗", + "runtimeStarting": "正在啟動", + "runtimeStopped": "未執行", + "managedByWebui": "由 WebUI 管理", + "officialGuide": "官方指南", + "optional": "選填", + "providerPreset": "服務供應商", + "requiredSetup": "必要設定", + "savedSecret": "已儲存", + "savedSecretPlaceholder": "已儲存的密鑰", + "savedSettings": "設定已儲存。", + "saveSettings": "儲存設定", + "selectChannel": "檢視 {{name}} 設定", + "setupSteps": "後續步驟", + "showSecret": "顯示密鑰", + "toggleChannel": "{{name}} 渠道", + "toggleInstance": "{{name}} 執行個體", + "tryIt": "試試看", + "validationFailed": "啟用前請檢查必要設定。", + "validation": { + "connected": "已連線", + "configured": "手動設定", + "needs_setup": "需要設定", + "invalid": "無效", + "unsupported": "手動設定" + } }, "nanobotFeatures": { "enabled": "已啟用", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 7c4ada3d..d650ea54 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -497,9 +497,9 @@ export async function runPairingAction( export async function startChannelConnect( token: string, - channel: "feishu" | "weixin", + channel: string, options: { - domain?: "feishu" | "lark"; + domain?: string; instanceId?: string; mode?: "replace" | "create"; force?: boolean; @@ -520,7 +520,7 @@ export async function startChannelConnect( export async function pollChannelConnect( token: string, - channel: "feishu" | "weixin", + channel: string, sessionId: string, base: string = "", ): Promise { @@ -534,7 +534,7 @@ export async function pollChannelConnect( export async function cancelChannelConnect( token: string, - channel: "feishu" | "weixin", + channel: string, sessionId: string, base: string = "", ): Promise { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 6dd5d392..39006b91 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -691,11 +691,18 @@ export interface CliAppsPayload { export interface NanobotFeatureInfo { name: string; display_name: string; + capabilities?: string[]; + settings_visible?: boolean; + webui?: string; type: "channel" | "feature" | string; enabled: boolean; + running?: boolean; + runtime_status?: ChannelRuntimeStatus; + runtime_error?: string; configured?: boolean; config_values?: Record; configured_fields?: string[]; + setup?: ChannelSetupContract; instances?: NanobotChannelInstanceInfo[]; installed: boolean; ready: boolean; @@ -704,19 +711,36 @@ export interface NanobotFeatureInfo { requires_restart: boolean; } +export interface ChannelSetupContractField { + key: string; + field: string; + kind: "string" | "secret" | "int" | "bool" | "list" | "enum" | string; + choices: string[]; + required: boolean; + default_value?: string; +} + +export interface ChannelSetupContract { + fields: ChannelSetupContractField[]; + official_url?: string; +} + export interface NanobotChannelInstanceInfo { id: string; name: string; display_name?: string; avatar_url?: string; - domain?: "feishu" | "lark" | string; enabled: boolean; + running?: boolean; + runtime_status?: ChannelRuntimeStatus; + runtime_error?: string; configured: boolean; - app_id?: string; - group_policy?: string; - allow_from?: string[]; + config_values: Record; + configured_fields: string[]; } +export type ChannelRuntimeStatus = "running" | "starting" | "failed" | "stopped" | string; + export interface NanobotFeaturesPayload { features: NanobotFeatureInfo[]; enabled_count: number; diff --git a/webui/src/tests/channel-catalog.test.ts b/webui/src/tests/channel-catalog.test.ts index 04a6db9c..0cab0187 100644 --- a/webui/src/tests/channel-catalog.test.ts +++ b/webui/src/tests/channel-catalog.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog"; +import { channelUiPresentation } from "@/channel-plugins/registry"; + +const SLACK_SOCKET_MODE_MANIFEST = channelUiPresentation("slack")?.setup?.actions?.find( + (action) => action.id === "slack-manifest", +)?.copyText; describe("Slack setup manifest", () => { it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])( diff --git a/webui/src/tests/channel-identity.test.ts b/webui/src/tests/channel-identity.test.ts new file mode 100644 index 00000000..03c1ed40 --- /dev/null +++ b/webui/src/tests/channel-identity.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { + channelIsRunning, + channelSetup, + channelStatusLabel, + channelToggleChecked, +} from "@/components/settings/channels/ChannelIdentity"; +import type { NanobotFeatureInfo } from "@/lib/types"; + +function feature(overrides: Partial): NanobotFeatureInfo { + return { + name: "plugin-chat", + display_name: "Plugin Chat", + type: "channel", + enabled: false, + installed: true, + ready: false, + status: "not_enabled", + install_supported: true, + requires_restart: true, + ...overrides, + }; +} + +describe("channelSetup", () => { + it("builds editable fields for a plugin-owned backend contract", () => { + const setup = channelSetup(feature({ + setup: { + fields: [ + { + key: "channels.plugin-chat.apiToken", + field: "apiToken", + kind: "secret", + choices: [], + required: true, + }, + { + key: "channels.plugin-chat.region", + field: "region", + kind: "enum", + choices: ["us", "eu"], + required: false, + }, + ], + official_url: "https://plugin.example/setup", + }, + })); + + expect(setup.officialUrl).toBe("https://plugin.example/setup"); + expect(setup.officialLabel).toBe("Open official setup"); + expect(setup.fields).toEqual([ + expect.objectContaining({ + key: "channels.plugin-chat.apiToken", + label: "Api Token", + secret: true, + optional: false, + }), + expect.objectContaining({ + key: "channels.plugin-chat.region", + options: [ + { value: "us", label: "Us" }, + { value: "eu", label: "Eu" }, + ], + }), + ]); + }); + + it("filters catalog-only fields that the backend does not accept", () => { + const setup = channelSetup(feature({ + name: "discord", + display_name: "Discord", + webui: "webui/index.ts", + setup: { + fields: [{ + key: "channels.discord.token", + field: "token", + kind: "secret", + choices: [], + required: true, + }], + }, + })); + + expect(setup.fields?.map((field) => field.key)).toEqual(["channels.discord.token"]); + expect(setup.manualFields).toBeUndefined(); + }); + + it("uses backend defaults and choices with catalog presentation labels", () => { + const setup = channelSetup(feature({ + name: "discord", + display_name: "Discord", + webui: "webui/index.ts", + setup: { + fields: [{ + key: "channels.discord.groupPolicy", + field: "groupPolicy", + kind: "enum", + choices: ["open"], + required: false, + default_value: "open", + }], + }, + })); + + expect(setup.fields).toEqual([ + expect.objectContaining({ + key: "channels.discord.groupPolicy", + label: "Group behavior", + defaultValue: "open", + options: [{ value: "open", label: "All messages" }], + }), + ]); + }); + + it("loads setup copy from the channel-owned locale", () => { + const setup = channelSetup(feature({ + name: "dingtalk", + display_name: "DingTalk", + webui: "webui/index.ts", + }), "zh-CN"); + + expect(setup.summary).toBe("钉钉需要 Stream 模式的应用凭据。"); + expect(setup.steps[0]).toBe("创建或选择一个已启用 Stream 模式的钉钉应用。"); + expect(setup.fields).toContainEqual(expect.objectContaining({ + key: "channels.dingtalk.allowFrom", + label: "允许的用户", + })); + }); +}); + +describe("channel runtime state", () => { + const tx = (_key: string, fallback: string) => fallback; + + it("only reports a channel on when the runtime is explicitly running", () => { + const running = feature({ enabled: true, runtime_status: "running" }); + const unknown = feature({ enabled: true }); + + expect(channelIsRunning(running)).toBe(true); + expect(channelToggleChecked(running)).toBe(true); + expect(channelStatusLabel(running, tx)).toBe("On"); + expect(channelIsRunning(unknown)).toBe(false); + expect(channelToggleChecked(unknown)).toBe(false); + expect(channelStatusLabel(unknown, tx)).toBe("Not running"); + }); +}); diff --git a/webui/src/tests/channel-locale-registry.test.ts b/webui/src/tests/channel-locale-registry.test.ts new file mode 100644 index 00000000..e66ea57a --- /dev/null +++ b/webui/src/tests/channel-locale-registry.test.ts @@ -0,0 +1,112 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { channelFieldMessageKey } from "@/channel-plugins/i18n"; +import { registeredChannelLocales } from "@/channel-plugins/locale-registry"; +import { registeredChannelUiContributions } from "@/channel-plugins/registry"; +import { supportedLocales } from "@/i18n/config"; + +const expectedChannels = [ + "dingtalk", + "discord", + "email", + "feishu", + "matrix", + "mattermost", + "msteams", + "napcat", + "qq", + "signal", + "slack", + "telegram", + "websocket", + "wecom", + "weixin", + "whatsapp", +]; + +function flatten(value: unknown, prefix = ""): Map { + const entries = new Map(); + if (typeof value === "string") { + entries.set(prefix, value); + return entries; + } + if (!value || typeof value !== "object") return entries; + + for (const [key, child] of Object.entries(value)) { + if (!prefix && key === "displayName") continue; + const childPrefix = prefix ? `${prefix}.${key}` : key; + for (const [childKey, text] of flatten(child, childPrefix)) { + entries.set(childKey, text); + } + } + return entries; +} + +function interpolationKeys(value: string): string[] { + return [...value.matchAll(/{{\s*([\w.-]+)\s*}}/g)] + .map((match) => match[1]) + .sort(); +} + +describe("channel locale registry", () => { + it("loads every supported locale from every built-in channel package", () => { + const registrations = registeredChannelLocales(); + expect([...registrations.keys()].sort()).toEqual(expectedChannels); + + for (const [channel, locales] of registrations) { + expect([...locales.keys()].sort()).toEqual( + supportedLocales.map(({ code }) => code).sort(), + ); + + const english = flatten(locales.get("en")); + for (const [locale, messages] of locales) { + const translated = flatten(messages); + expect([...translated.keys()].sort(), `${channel}/${locale} message keys`).toEqual( + [...english.keys()].sort(), + ); + for (const [key, source] of english) { + expect( + interpolationKeys(translated.get(key) ?? ""), + `${channel}/${locale}:${key} interpolation keys`, + ).toEqual(interpolationKeys(source)); + } + } + } + }); + + it("keeps structural UI definitions aligned with English locale keys", () => { + const locales = registeredChannelLocales(); + for (const { channel, contribution } of registeredChannelUiContributions()) { + const messages = locales.get(channel)?.get("en"); + expect(messages, `${channel} English messages`).toBeDefined(); + + const setup = contribution.presentation.setup; + for (const field of [...(setup?.fields ?? []), ...(setup?.manualFields ?? [])]) { + const messageKey = channelFieldMessageKey(channel, field.key); + expect(messages?.setup.fields?.[messageKey], `${channel} field ${messageKey}`).toBeDefined(); + } + for (const action of setup?.actions ?? []) { + expect(messages?.setup.actions?.[action.id], `${channel} action ${action.id}`).toBeTypeOf("string"); + } + for (const preset of setup?.presets ?? []) { + expect(messages?.setup.presets?.[preset.id], `${channel} preset ${preset.id}`).toBeTypeOf("string"); + } + } + }); + + it("keeps i18n initialization independent from channel React modules", () => { + const localeRegistry = readFileSync( + resolve(process.cwd(), "src/channel-plugins/locale-registry.ts"), + "utf8", + ); + const i18nEntry = readFileSync(resolve(process.cwd(), "src/i18n/index.ts"), "utf8"); + + expect(localeRegistry).toContain("webui/locales/*.json"); + expect(localeRegistry).not.toMatch(/channel-plugins\/registry|\.tsx|\breact\b/i); + expect(i18nEntry).toContain("channel-plugins/locale-registry"); + expect(i18nEntry).not.toContain("channel-plugins/registry"); + }); +}); diff --git a/webui/src/tests/channel-ui-registry.test.ts b/webui/src/tests/channel-ui-registry.test.ts new file mode 100644 index 00000000..1691af56 --- /dev/null +++ b/webui/src/tests/channel-ui-registry.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + channelUiContribution, + channelUiOwner, + channelUiPresentation, + registeredChannelUiContributions, +} from "@/channel-plugins/registry"; + +describe("channel UI contributions", () => { + it("selects channel-owned UI only through the backend manifest entry", () => { + expect(channelUiContribution("feishu", "webui/index.tsx")?.Panel).toBeTypeOf("function"); + expect(channelUiContribution("weixin", "webui/index.tsx")?.ConnectFlow).toBeTypeOf("function"); + expect(channelUiContribution("feishu", undefined)).toBeUndefined(); + expect(channelUiContribution("feishu", "webui/missing.tsx")).toBeUndefined(); + expect(channelUiContribution("missing", "webui/index.tsx")).toBeUndefined(); + + const registrations = registeredChannelUiContributions(); + const channels = registrations.map((entry) => entry.channel); + expect(channels).toEqual(expect.arrayContaining(["feishu", "weixin"])); + expect(new Set(channels).size).toBe(channels.length); + expect(registrations.every((entry) => /^webui\/index\.tsx?$/.test(entry.webui))).toBe(true); + expect(channelUiContribution("slack", "webui/index.ts")?.presentation.displayName).toBe("Slack"); + }); + + it("keeps aliases inside the owning channel contribution", () => { + expect(channelUiPresentation("lark")?.displayName).toBe("Lark"); + expect(channelUiPresentation("wechat")?.displayName).toBe("WeChat"); + expect(channelUiOwner("lark")).toBe("feishu"); + expect(channelUiOwner("wechat")).toBe("weixin"); + }); + + it("uses the DingTalk Open Platform brand mark", () => { + expect(channelUiPresentation("dingtalk")?.logoUrl).toBe( + "https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg", + ); + }); + + it("keeps the core setup panel independent of concrete channel plugins", () => { + const source = readFileSync( + resolve(process.cwd(), "src/components/settings/channels/ChannelSetupPanel.tsx"), + "utf8", + ); + + expect(source).not.toMatch(/feature\.name\s*===\s*["'](?:feishu|weixin)["']/); + expect(source).not.toMatch(/channel-plugins\/(?:feishu|weixin)/); + expect(source).not.toMatch(/(?:Feishu|Weixin)(?:AssistantsPanel|ConnectFlow)/); + }); + + it("discovers UI contributions only from channel-owned packages", () => { + const source = readFileSync( + resolve(process.cwd(), "src/channel-plugins/registry.ts"), + "utf8", + ); + + expect(source).toContain("../../../nanobot/channels/*/webui/**/*.{ts,tsx}"); + expect(source).not.toContain('"./*/index.tsx"'); + }); + + it("derives channel identity from the package directory", () => { + for (const channel of ["feishu", "weixin"]) { + const source = readFileSync( + resolve(process.cwd(), `../nanobot/channels/${channel}/webui/index.tsx`), + "utf8", + ); + expect(source).not.toMatch(/\bchannel\s*:/); + } + }); + + it("includes channel-owned UI in Tailwind's production scan", () => { + const source = readFileSync(resolve(process.cwd(), "tailwind.config.js"), "utf8"); + + expect(source).toContain("../nanobot/channels/*/webui/**/*.{ts,tsx}"); + }); +}); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index d9eb54cd..266515c8 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -136,6 +136,48 @@ const LOCALIZED_WORKSPACE_COPY_KEYS = [ "workspace.dialog.usePath", "workspace.dialog.absolutePathRequired", ]; +const LOCALIZED_CHANNEL_SHELL_KEYS = [ + "settings.channels.advanced", + "settings.channels.checkAndEnable", + "settings.channels.checkConnection", + "settings.channels.checkedAndEnabled", + "settings.channels.checking", + "settings.channels.checkOnly", + "settings.channels.commandCopied", + "settings.channels.commandCopyFailed", + "settings.channels.configuredInstances", + "settings.channels.connectPreview", + "settings.channels.copyCommand", + "settings.channels.filterAll", + "settings.channels.filterOff", + "settings.channels.filterOn", + "settings.channels.helperCopied", + "settings.channels.helperCopyFailed", + "settings.channels.hideSecret", + "settings.channels.instanceConfigured", + "settings.channels.instanceNeedsSetup", + "settings.channels.managedByWebui", + "settings.channels.officialGuide", + "settings.channels.optional", + "settings.channels.providerPreset", + "settings.channels.requiredSetup", + "settings.channels.savedSecret", + "settings.channels.savedSecretPlaceholder", + "settings.channels.savedSettings", + "settings.channels.saveSettings", + "settings.channels.selectChannel", + "settings.channels.setupSteps", + "settings.channels.showSecret", + "settings.channels.toggleChannel", + "settings.channels.toggleInstance", + "settings.channels.tryIt", + "settings.channels.validation.connected", + "settings.channels.validation.configured", + "settings.channels.validation.invalid", + "settings.channels.validation.needs_setup", + "settings.channels.validation.unsupported", + "settings.channels.validationFailed", +]; const INDEX_HTML = readFileSync(resolve(process.cwd(), "index.html"), "utf8"); const PREBOOT_SCRIPT = INDEX_HTML.match( /