mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab8783758a | ||
|
|
e3de01c9f6 | ||
|
|
462a0dfb0f | ||
|
|
7aaac37bca | ||
|
|
91514ad0b1 | ||
|
|
a6b68178aa | ||
|
|
2099cb009e | ||
|
|
39a952ecce | ||
|
|
b1232fdaf4 | ||
|
|
cea8617096 | ||
|
|
ffb7ddfa1e | ||
|
|
c2071594cf | ||
|
|
cf96c4d5e9 | ||
|
|
cf00f537bd | ||
|
|
cfa49c6e78 | ||
|
|
c062e1af14 | ||
|
|
c77379099b | ||
|
|
ca873e4d17 | ||
|
|
63895fc101 | ||
|
|
770d89b430 | ||
|
|
afed32b013 | ||
|
|
8c68c6fe1e | ||
|
|
b76d54aae1 | ||
|
|
d35f99abfc | ||
|
|
d4f5abe004 | ||
|
|
07ad0bafa8 | ||
|
|
995cc44e89 | ||
|
|
7ac9a46978 | ||
|
|
fe0e65928d | ||
|
|
85097aa143 | ||
|
|
6de5a0c5ca | ||
|
|
8a48af7c74 | ||
|
|
b4adb29c2b | ||
|
|
6519737860 | ||
|
|
d4e0294734 | ||
|
|
681edfa6f3 | ||
|
|
ba86dccc8d | ||
|
|
5ed28a6744 | ||
|
|
aa70aa48f9 | ||
|
|
0fd4d0ab29 | ||
|
|
c1fd76add3 | ||
|
|
63a6d5d07d | ||
|
|
dcb37259fa | ||
|
|
88c38e9b38 | ||
|
|
37165b0db0 | ||
|
|
2116e32013 | ||
|
|
06f47fa540 | ||
|
|
905da8e34a | ||
|
|
5365bab088 | ||
|
|
f718c69b2d | ||
|
|
07f54c25e3 | ||
|
|
1a1e666625 | ||
|
|
297a9e5939 | ||
|
|
86f6558707 | ||
|
|
4916fc07ab | ||
|
|
11eb9d8cc8 | ||
|
|
6c9e3a2cc3 | ||
|
|
b7048cf76a | ||
|
|
b2759e8a6b | ||
|
|
1643aa7ef5 | ||
|
|
9f8c2cb1bf | ||
|
|
61afbffc89 | ||
|
|
9cdf17f5d5 | ||
|
|
3b14d59dcd | ||
|
|
a335ce07db | ||
|
|
87478b6e92 | ||
|
|
67648774e2 |
@@ -19,14 +19,25 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
name: Python (${{ matrix.name }})
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
|
include:
|
||||||
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
- name: minimum, 3.11
|
||||||
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
os: ubuntu-latest
|
||||||
|
python-version: "3.11"
|
||||||
|
coverage: false
|
||||||
|
- name: latest, 3.14 + coverage
|
||||||
|
os: ubuntu-latest
|
||||||
|
python-version: "3.14"
|
||||||
|
coverage: true
|
||||||
|
- name: Windows, 3.14
|
||||||
|
os: windows-latest
|
||||||
|
python-version: "3.14"
|
||||||
|
coverage: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -47,10 +58,21 @@ jobs:
|
|||||||
run: uv sync --all-extras --dev
|
run: uv sync --all-extras --dev
|
||||||
|
|
||||||
- name: Lint with ruff
|
- name: Lint with ruff
|
||||||
run: uv run ruff check nanobot --select F
|
if: matrix.coverage
|
||||||
|
run: uv run ruff check nanobot tests conftest.py
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests with coverage
|
||||||
run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered
|
if: matrix.coverage
|
||||||
|
run: >-
|
||||||
|
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
|
||||||
|
--durations=25 --durations-min=1.0
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -64,9 +86,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: 1.3.6
|
bun-version: 1.3.6
|
||||||
|
|
||||||
|
- name: Verify npm lockfile
|
||||||
|
working-directory: webui
|
||||||
|
run: npm ci --ignore-scripts --dry-run
|
||||||
|
|
||||||
- name: Install WebUI dependencies
|
- name: Install WebUI dependencies
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: bun install
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Lint WebUI
|
- name: Lint WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
|
|||||||
|
|
||||||
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
|
- **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.
|
- **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.
|
- **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.
|
- **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`).
|
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
|
||||||
|
|||||||
+15
-1
@@ -28,6 +28,12 @@ COPY nanobot/ nanobot/
|
|||||||
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
|
||||||
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
|
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
|
||||||
|
|
||||||
|
# Render deploy template (see render.yaml): committed gateway config that wires
|
||||||
|
# secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved
|
||||||
|
# at startup). Lives in the code dir (/app), not the data dir, so a mounted disk
|
||||||
|
# won't shadow it. Only used when RENDER=true; ignored by local runs.
|
||||||
|
COPY render-config.json ./
|
||||||
|
|
||||||
# Create non-root user and config directory
|
# Create non-root user and config directory
|
||||||
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
||||||
mkdir -p /home/nanobot/.nanobot && \
|
mkdir -p /home/nanobot/.nanobot && \
|
||||||
@@ -36,8 +42,16 @@ RUN useradd -m -u 1000 -s /bin/bash nanobot && \
|
|||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
|
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
USER nanobot
|
# Start as root so the entrypoint can chown the data dir (on Render, the
|
||||||
|
# freshly-mounted root-owned persistent disk) before dropping to the non-root
|
||||||
|
# nanobot user via setpriv. The entrypoint drops privileges on every root start
|
||||||
|
# and fails closed if it cannot, so the agent never runs as root (see
|
||||||
|
# entrypoint.sh).
|
||||||
|
USER root
|
||||||
ENV HOME=/home/nanobot
|
ENV HOME=/home/nanobot
|
||||||
|
# Ensure crash output reaches Render logs (app output is otherwise swallowed on
|
||||||
|
# non-graceful exit).
|
||||||
|
ENV PYTHONUNBUFFERED=1 PYTHONFAULTHANDLER=1
|
||||||
|
|
||||||
# Gateway health endpoint and optional WebUI/WebSocket channel ports
|
# Gateway health endpoint and optional WebUI/WebSocket channel ports
|
||||||
EXPOSE 18790 8765
|
EXPOSE 18790 8765
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
|
||||||
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
||||||
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
||||||
|
| Deploy to the cloud in one click | [Deploy to Render](#deploy-to-render) |
|
||||||
|
|
||||||
|
## Deploy to Render
|
||||||
|
|
||||||
|
Deploy nanobot's gateway and bundled WebUI as a single web service with persistent memory. Render reads [`render.yaml`](./render.yaml) and prompts for two secrets on deploy: `ANTHROPIC_API_KEY` and `NANOBOT_WEB_TOKEN` (the password that gates the public WebUI — generate a strong random value, e.g. `openssl rand -hex 32`).
|
||||||
|
|
||||||
|
> **Note:** The blueprint attaches a persistent disk so sessions, memory, and WebUI history survive restarts. Persistent disks require a paid service (they are not available on Render's free tier).
|
||||||
|
|
||||||
|
[](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
|
||||||
|
|
||||||
## What can nanobot do?
|
## What can nanobot do?
|
||||||
|
|
||||||
@@ -101,13 +110,13 @@ For older updates, see the [release archive](./docs/release-archive.md) or [GitH
|
|||||||
## 📦 Install
|
## 📦 Install
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> If you want the newest features and experiments, install from source.
|
> If you want the newest features and experiments, install from source.
|
||||||
>
|
>
|
||||||
> If you want the most stable day-to-day experience, install from PyPI or with `uv`.
|
> If you want the most stable day-to-day experience, install from PyPI or with `uv`.
|
||||||
|
|
||||||
Pick **one** install method:
|
Pick **one** install method:
|
||||||
|
|
||||||
Prerequisites: Python 3.11 or newer. Git is only needed for a source install; Node.js/Bun are only needed if you are developing the WebUI itself.
|
Prerequisites: Python 3.11 or newer. Git is only needed for a source install. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it.
|
||||||
|
|
||||||
If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path.
|
If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path.
|
||||||
|
|
||||||
@@ -125,7 +134,7 @@ Windows PowerShell:
|
|||||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**.
|
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. The installer also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
|
||||||
|
|
||||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||||
|
|
||||||
@@ -165,18 +174,24 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c
|
|||||||
|
|
||||||
**Install from source**
|
**Install from source**
|
||||||
|
|
||||||
|
`bun` or `npm` must be available. From an activated virtual environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/HKUDS/nanobot.git
|
git clone https://github.com/HKUDS/nanobot.git
|
||||||
cd nanobot
|
cd nanobot
|
||||||
python -m pip install -e .
|
python -m pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On Windows, if pip reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install. Contributors who need an editable checkout should follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`webui/README.md`](./webui/README.md).
|
||||||
|
|
||||||
Verify the install:
|
Verify the install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot --version
|
nanobot --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `nanobot` is not on `PATH`, invoke it through the method that installed it: reuse the recommended installer's command, use `uv tool run --from nanobot-ai nanobot ...` or `pipx run --spec nanobot-ai nanobot ...`, or use the Python executable from the environment where pip installed the package.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
@@ -246,13 +261,13 @@ For another provider, the same config shape still applies:
|
|||||||
|
|
||||||
**3. Open the WebUI**
|
**3. Open the WebUI**
|
||||||
|
|
||||||
Start the browser workbench:
|
The stable-compatible path is:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot webui
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. It binds the first-run WebUI to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot webui --background`, then manage the gateway with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
Leave the terminal open and visit `http://127.0.0.1:8765`. Current source versions also provide `nanobot webui`, which prepares the local WebSocket channel if needed, starts the gateway, and opens the browser automatically. The first-run WebUI binds to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||||
|
|
||||||
For manual or terminal-only setup, test one CLI message:
|
For manual or terminal-only setup, test one CLI message:
|
||||||
|
|
||||||
@@ -292,7 +307,7 @@ The WebUI ships **inside the published wheel** — no extra build step. It is th
|
|||||||
nanobot webui
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
The command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
On current source versions, the command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). If your installed stable release does not include `nanobot webui`, run `nanobot gateway` and open that address manually. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
||||||
|
|
||||||
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
|
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
|
||||||
|
|
||||||
@@ -366,7 +381,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, review, and contribution gui
|
|||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
This project was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and continues to be maintained in an individual capacity using personal resources, with contributions from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ pip install --upgrade nanobot-ai
|
|||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
- Keep `litellm` updated to the latest version for security fixes
|
- 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
|
- Subscribe to security advisories for nanobot and its dependencies
|
||||||
|
|
||||||
### 7. Production Deployment
|
### 7. Production Deployment
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
"""Cross-suite test infrastructure."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import certifi
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
|
||||||
|
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
|
||||||
|
|
||||||
|
Loading certifi takes roughly 0.7 seconds per client on Windows. The test
|
||||||
|
suite constructs hundreds of clients while mocking their I/O. System roots
|
||||||
|
preserve certificate verification for accidental local requests; explicit
|
||||||
|
``cafile``, ``capath``, and ``cadata`` arguments still use the real loader.
|
||||||
|
"""
|
||||||
|
if sys.platform != "win32":
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
original = ssl.create_default_context
|
||||||
|
certifi_path = os.path.normcase(os.path.abspath(certifi.where()))
|
||||||
|
|
||||||
|
def create_default_context(
|
||||||
|
purpose: ssl.Purpose = ssl.Purpose.SERVER_AUTH,
|
||||||
|
*,
|
||||||
|
cafile: str | None = None,
|
||||||
|
capath: str | None = None,
|
||||||
|
cadata: str | bytes | None = None,
|
||||||
|
) -> ssl.SSLContext:
|
||||||
|
requested_path = os.path.normcase(os.path.abspath(cafile)) if cafile else None
|
||||||
|
if requested_path == certifi_path and capath is None and cadata is None:
|
||||||
|
return original(purpose)
|
||||||
|
return original(
|
||||||
|
purpose,
|
||||||
|
cafile=cafile,
|
||||||
|
capath=capath,
|
||||||
|
cadata=cadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
ssl.create_default_context = create_default_context
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
ssl.create_default_context = original
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
x-bwrap-security: &bwrap-security
|
||||||
|
cap_add:
|
||||||
|
- SYS_ADMIN
|
||||||
|
security_opt:
|
||||||
|
- apparmor=unconfined
|
||||||
|
- seccomp=unconfined
|
||||||
|
|
||||||
|
services:
|
||||||
|
nanobot-gateway:
|
||||||
|
<<: *bwrap-security
|
||||||
|
|
||||||
|
nanobot-api:
|
||||||
|
<<: *bwrap-security
|
||||||
|
|
||||||
|
nanobot-cli:
|
||||||
|
<<: *bwrap-security
|
||||||
@@ -6,11 +6,6 @@ x-common-config: &common-config
|
|||||||
- ~/.nanobot:/home/nanobot/.nanobot
|
- ~/.nanobot:/home/nanobot/.nanobot
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
cap_add:
|
|
||||||
- SYS_ADMIN
|
|
||||||
security_opt:
|
|
||||||
- apparmor=unconfined
|
|
||||||
- seccomp=unconfined
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nanobot-gateway:
|
nanobot-gateway:
|
||||||
|
|||||||
+60
-126
@@ -1,150 +1,84 @@
|
|||||||
# nanobot Docs
|
# nanobot Documentation
|
||||||
|
|
||||||
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
|
Use these docs to get a working agent first, then open a task guide only when you need the next capability. Source-level design and extension details are kept in the contributor section.
|
||||||
|
|
||||||
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md), open the browser workbench with `nanobot webui`, and use terminal checks when you need lower-level diagnosis.
|
Repository docs follow the current source tree and can be newer than the latest package release. For published release docs, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview).
|
||||||
|
|
||||||
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
|
|
||||||
|
|
||||||
Provider examples are concrete walkthroughs, not rankings or endorsements. Use the provider whose key, endpoint, and model ID you actually control.
|
|
||||||
|
|
||||||
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
|
|
||||||
|
|
||||||
## Pick a Track
|
|
||||||
|
|
||||||
| You are | Start with | Then use |
|
|
||||||
|---|---|---|
|
|
||||||
| New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails |
|
|
||||||
| Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups |
|
|
||||||
| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) |
|
|
||||||
| Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
|
||||||
|
|
||||||
## Start Here
|
## Start Here
|
||||||
|
|
||||||
| Goal | Read | Outcome |
|
| Your situation | Read this | You are done when... |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Start with no technical background | [`start-without-technical-background.md`](./start-without-technical-background.md) | One-command setup, terminal basics, config, API keys, and the first reply |
|
| Terminals, Python, or API keys are new to you | [Beginner walkthrough](./start-without-technical-background.md) | The browser can send `Hello!` and receive a reply |
|
||||||
| Install and get the first reply | [`quick-start.md`](./quick-start.md) | A working CLI agent and a known-good config path |
|
| You are comfortable running commands | [Install and Quick Start](./quick-start.md) | `nanobot status` is healthy and the WebUI or CLI can get one reply |
|
||||||
| Understand how the pieces fit | [`concepts.md`](./concepts.md) | Mental model for config, workspace, gateway, channels, tools, memory, and sessions |
|
| Something already failed | [Troubleshooting](./troubleshooting.md) | You have isolated the problem to install, config, model, gateway, channel, or tool access |
|
||||||
| Choose or change a model provider | [`providers.md`](./providers.md) | Correct provider/model pairing without reading the full config reference |
|
|
||||||
| Copy a provider setup recipe | [`provider-cookbook.md`](./provider-cookbook.md) | Pasteable OpenRouter, OpenAI, Anthropic, local model, fallback, and Langfuse setups |
|
|
||||||
| Fix a first-run or runtime problem | [`troubleshooting.md`](./troubleshooting.md) | A diagnosis order and targeted checks for common failures |
|
|
||||||
|
|
||||||
## Task Guides
|
The recommended first-run path is:
|
||||||
|
|
||||||
Use these pages when you know the workflow you want and do not want to scan the
|
1. Install nanobot.
|
||||||
full reference first.
|
2. Choose **Quick Start** in `nanobot onboard --wizard`.
|
||||||
|
3. Run `nanobot gateway` and open `http://127.0.0.1:8765`.
|
||||||
|
4. Send `Hello!` before configuring anything else.
|
||||||
|
|
||||||
|
Most people do not need to edit JSON for the first run. The wizard handles the initial provider, model, and local WebUI settings. Current source versions also provide `nanobot webui` to start the gateway and open the browser in one step. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
|
||||||
|
|
||||||
|
## Add One Capability
|
||||||
|
|
||||||
|
Pick the row that matches what you want to accomplish next:
|
||||||
|
|
||||||
| Goal | Guide |
|
| Goal | Guide |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Build a personal AI agent | [`guides/build-a-personal-ai-agent.md`](./guides/build-a-personal-ai-agent.md) |
|
| Learn the browser workbench | [WebUI](./webui.md) |
|
||||||
| Run a self-hosted AI agent | [`guides/self-hosted-ai-agent.md`](./guides/self-hosted-ai-agent.md) |
|
| Connect Telegram, Discord, Slack, Feishu, WeChat, Email, or another chat app | [Chat Apps](./chat-apps.md) |
|
||||||
| Use a browser AI agent WebUI | [`guides/ai-agent-webui.md`](./guides/ai-agent-webui.md) |
|
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
||||||
| Connect an AI agent to chat apps | [`guides/chat-app-ai-agent.md`](./guides/chat-app-ai-agent.md) |
|
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||||
| Run long-running agent tasks | [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) |
|
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
||||||
| Schedule or trigger agent turns | [`automations.md`](./automations.md) |
|
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||||
| Add long-term agent memory | [`guides/ai-agent-memory.md`](./guides/ai-agent-memory.md) |
|
| Generate images | [Image Generation](./image-generation.md) |
|
||||||
| Add MCP tools to an agent | [`guides/mcp-tools-for-ai-agents.md`](./guides/mcp-tools-for-ai-agents.md) |
|
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
||||||
| Run an agent from Python | [`guides/python-ai-agent-sdk.md`](./guides/python-ai-agent-sdk.md) |
|
| Understand and manage long-term memory | [Memory](./memory.md) |
|
||||||
| Expose an OpenAI-compatible agent API | [`guides/openai-compatible-agent-api.md`](./guides/openai-compatible-agent-api.md) |
|
| Run nanobot continuously | [Deployment](./deployment.md) |
|
||||||
| Deploy a long-running agent gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
| Run separate bots or workspaces | [Multiple Instances](./multiple-instances.md) |
|
||||||
|
| Call nanobot from Python | [Python SDK](./python-sdk.md) |
|
||||||
|
| Expose an OpenAI-compatible endpoint | [OpenAI-Compatible API](./openai-api.md) |
|
||||||
|
|
||||||
Platform-specific chat guides:
|
For shorter, outcome-focused walkthroughs, browse the [task guide index](./guides/README.md).
|
||||||
[`Telegram`](./guides/telegram-ai-agent.md),
|
|
||||||
[`Discord`](./guides/discord-ai-agent.md),
|
|
||||||
[`Slack`](./guides/slack-ai-agent.md),
|
|
||||||
[`Feishu`](./guides/feishu-ai-agent.md),
|
|
||||||
[`WhatsApp`](./guides/whatsapp-ai-agent.md),
|
|
||||||
[`WeChat`](./guides/wechat-ai-agent.md),
|
|
||||||
[`QQ`](./guides/qq-ai-agent.md),
|
|
||||||
[`Email`](./guides/email-ai-agent.md), and
|
|
||||||
[`Mattermost`](./guides/mattermost-ai-agent.md).
|
|
||||||
|
|
||||||
Configuration guides:
|
## Operate nanobot
|
||||||
[`MCP tools`](./guides/configure-mcp-tools.md),
|
|
||||||
[`web search`](./guides/configure-web-search.md),
|
|
||||||
[`model fallback`](./guides/configure-model-fallback.md),
|
|
||||||
[`OpenAI-compatible providers`](./guides/configure-openai-compatible-provider.md),
|
|
||||||
[`Langfuse`](./guides/configure-langfuse-observability.md),
|
|
||||||
[`local security`](./guides/secure-local-ai-agent.md), and
|
|
||||||
[`gateway deployment`](./guides/deploy-nanobot-gateway.md).
|
|
||||||
|
|
||||||
## After the First Reply Works
|
| Need | Read |
|
||||||
|
|---|---|
|
||||||
Do not configure everything at once. Pick one next surface:
|
| Commands and flags | [CLI Reference](./cli-reference.md) |
|
||||||
|
| In-chat slash commands | [In-Chat Commands](./chat-commands.md) |
|
||||||
If a local `nanobot agent` session can already answer normally, you can also ask nanobot to help configure itself: have it read the relevant docs, inspect your current config, make one specific next change, and tell you when to run `/restart`.
|
| Config, workspace, gateway, sessions, tools, and memory in plain language | [Concepts](./concepts.md) |
|
||||||
|
| Provider/model matching and selection | [Providers and Models](./providers.md) |
|
||||||
| Next goal | Read | First check |
|
| Setup and runtime diagnosis | [Troubleshooting](./troubleshooting.md) |
|
||||||
|---|---|---|
|
| Older development highlights | [Release Archive](./release-archive.md) |
|
||||||
| Use nanobot in a browser | [`webui.md`](./webui.md) | Run `nanobot webui` and open the local browser workbench |
|
|
||||||
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
|
|
||||||
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
|
|
||||||
| Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn |
|
|
||||||
| Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean |
|
|
||||||
| Diagnose a new failure | [`troubleshooting.md`](./troubleshooting.md) | Start with `nanobot status`, then `nanobot agent -m "Hello!"` |
|
|
||||||
|
|
||||||
## Use nanobot
|
|
||||||
|
|
||||||
| Goal | Read | Outcome |
|
|
||||||
|---|---|---|
|
|
||||||
| Open the bundled browser UI | [`webui.md`](./webui.md) | `nanobot webui`, chat workspace, Apps, Skills, Automations, and settings |
|
|
||||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
|
||||||
| Use automations | [`automations.md`](./automations.md) | Scheduled automations, local triggers, heartbeat, WebUI management, and delivery behavior |
|
|
||||||
| Use slash commands | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
|
|
||||||
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
|
||||||
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
|
|
||||||
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
|
|
||||||
| Join agent communities | [`agent-social-network.md`](./agent-social-network.md) | External agent-community setup |
|
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
| Area | Read | Best for |
|
Use reference pages to look up an exact option after you know what you are trying to configure:
|
||||||
|---|---|---|
|
|
||||||
| Full configuration schema | [`configuration.md`](./configuration.md) | Exact fields, defaults, provider tables, web tools, MCP, security, and runtime options |
|
|
||||||
| CLI commands | [`cli-reference.md`](./cli-reference.md) | Command names, common flags, and entrypoints |
|
|
||||||
| Architecture | [`architecture.md`](./architecture.md) | Source-level runtime map for core flow, providers, channels, tools, WebUI, memory, security, and extension points |
|
|
||||||
| Release archive | [`release-archive.md`](./release-archive.md) | Older release and daily update highlights moved out of the README |
|
|
||||||
| Development | [`development.md`](./development.md) | Contributor notes for adding providers and transcription adapters |
|
|
||||||
| Memory | [`memory.md`](./memory.md) | Session history, Dream consolidation, memory files, and versioning |
|
|
||||||
| Observability | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | Langfuse tracing setup and required environment variables |
|
|
||||||
| WebSocket protocol | [`websocket.md`](./websocket.md) | Custom clients, token issuance, multiplexed chats, media, and protocol events |
|
|
||||||
| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | `/v1/chat/completions`, `/v1/models`, file uploads, and SDK-compatible usage |
|
|
||||||
| Python SDK | [`python-sdk.md`](./python-sdk.md) | SDK 101, sessions, streaming, model overrides, runtime helpers, and hooks |
|
|
||||||
| Runtime self-inspection | [`my-tool.md`](./my-tool.md) | Inspecting and tuning the current agent run |
|
|
||||||
|
|
||||||
## Fast Lookup
|
| Area | Reference |
|
||||||
|
|
||||||
| Need | Jump to |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Provider/model resolution order | [`providers.md#provider-resolution`](./providers.md#provider-resolution) |
|
| Every configuration field and default | [Configuration](./configuration.md) |
|
||||||
| Model presets and fallback chains | [`providers.md#model-presets`](./providers.md#model-presets) and [`providers.md#fallback-models`](./providers.md#fallback-models) |
|
| Provider and model behavior | [Providers and Models](./providers.md) |
|
||||||
| Langfuse environment variables | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) |
|
| Chat channel prerequisites and manual JSON | [Chat Apps](./chat-apps.md) |
|
||||||
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
|
| WebSocket authentication and wire protocol | [WebSocket](./websocket.md) |
|
||||||
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
|
| Python SDK classes, events, sessions, and hooks | [Python SDK](./python-sdk.md) |
|
||||||
| Python SDK usage | [`python-sdk.md`](./python-sdk.md) |
|
| OpenAI-compatible HTTP routes and payloads | [OpenAI-Compatible API](./openai-api.md) |
|
||||||
| Scheduled automations and local triggers | [`automations.md`](./automations.md) |
|
| Runtime self-inspection and tuning | [My Tool](./my-tool.md) |
|
||||||
| Multiple configs, workspaces, and ports | [`multiple-instances.md`](./multiple-instances.md) |
|
|
||||||
| Security, sandboxing, and SSRF controls | [`configuration.md#security`](./configuration.md#security) |
|
|
||||||
| Channel plugin development | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
|
|
||||||
|
|
||||||
## Extend nanobot
|
Configuration examples are usually snippets to merge into `~/.nanobot/config.json`, not complete replacement files. The docs use camelCase because nanobot writes config that way. Keep real API keys, bot tokens, and passwords out of issues and public logs.
|
||||||
|
|
||||||
| Goal | Read | Outcome |
|
## Extend or Contribute
|
||||||
|---|---|---|
|
|
||||||
| Add a provider or transcription adapter | [`development.md`](./development.md) | A registry/schema-aligned implementation path |
|
|
||||||
| Add a chat channel plugin | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | A packaged channel discovered through entry points |
|
|
||||||
| Add custom MCP servers | [`configuration.md#mcp-model-context-protocol`](./configuration.md#mcp-model-context-protocol) | External tools exposed to the agent through MCP |
|
|
||||||
| Tune tool safety | [`configuration.md#security`](./configuration.md#security) | Shell sandboxing, workspace restriction, and SSRF policy |
|
|
||||||
|
|
||||||
## Reading Strategy
|
These pages explain implementation and extension points. You do not need them to install or operate nanobot.
|
||||||
|
|
||||||
Use the docs in this order when you are unsure where to go:
|
| Goal | Read |
|
||||||
|
|---|---|
|
||||||
|
| 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 Package Guide](./channel-package-guide.md) |
|
||||||
|
| Build the WebUI source | [WebUI Development](../webui/README.md) |
|
||||||
|
|
||||||
1. If terminal commands or config files are new to you, [`start-without-technical-background.md`](./start-without-technical-background.md) explains the setup words and uses one concrete provider example so there is only one decision at a time.
|
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.
|
||||||
2. [`quick-start.md`](./quick-start.md) proves installation, config loading, and provider access.
|
|
||||||
3. [`concepts.md`](./concepts.md) explains the runtime model so later pages are easier to scan.
|
|
||||||
4. [`provider-cookbook.md`](./provider-cookbook.md) gives pasteable provider, fallback, local model, and Langfuse recipes.
|
|
||||||
5. A task guide, such as [`chat-apps.md`](./chat-apps.md), [`image-generation.md`](./image-generation.md), or [`deployment.md`](./deployment.md), gets one workflow working.
|
|
||||||
6. [`configuration.md`](./configuration.md) is the source of truth when you need a specific field, default value, or advanced option.
|
|
||||||
7. [`troubleshooting.md`](./troubleshooting.md) helps isolate whether a failure is install, config, provider, gateway, channel, or tool related.
|
|
||||||
|
|||||||
@@ -81,11 +81,11 @@ Main files:
|
|||||||
| Area | Files |
|
| Area | Files |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Base channel contract | `nanobot/channels/base.py` |
|
| Base channel contract | `nanobot/channels/base.py` |
|
||||||
| Built-in channels | `nanobot/channels/*.py` |
|
| Channel packages | `nanobot/channels/<channel>/` |
|
||||||
| Discovery and lifecycle | `nanobot/channels/manager.py` |
|
| 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
|
## WebUI and Gateway
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
|
|||||||
| Extension | How |
|
| 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 |
|
| 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 |
|
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
|
||||||
| MCP | Add `tools.mcpServers` config |
|
| MCP | Add `tools.mcpServers` config |
|
||||||
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
|
||||||
|
|||||||
@@ -0,0 +1,792 @@
|
|||||||
|
# Channel Package Guide
|
||||||
|
|
||||||
|
Use this guide to add a self-contained channel package to the nanobot repository. A channel is part of nanobot when its package lives at `nanobot/channels/<channel>/`; there is no separate external channel-plugin path.
|
||||||
|
|
||||||
|
> **Breaking change:** nanobot no longer discovers the `nanobot.channels` Python entry-point group. Move an entry-point implementation into `nanobot/channels/<channel>/` with a package-owned manifest, runtime, tests, and optional WebUI contribution.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
When `nanobot gateway` starts, nanobot scans the packages under `nanobot/channels/` and loads each dependency-free `ChannelPlugin` descriptor from `manifest.py`.
|
||||||
|
|
||||||
|
If a matching config section has `"enabled": true`, the channel is instantiated and started.
|
||||||
|
|
||||||
|
## Ownership and Sources of Truth
|
||||||
|
|
||||||
|
| Concern | Owner and source of truth |
|
||||||
|
|---------|---------------------------|
|
||||||
|
| Runtime behavior and platform SDK use | `runtime.py` and package-local helpers |
|
||||||
|
| Python package requirements | `ChannelPlugin.dependencies` in `manifest.py` |
|
||||||
|
| Writable settings fields, types, defaults, requirements, secret handling, and validation | `ChannelPlugin.setup` in `manifest.py` |
|
||||||
|
| Persisted config expansion, instance updates, and runtime naming | `ChannelPlugin.management` backed by a dependency-free module |
|
||||||
|
| Interactive setup connections and their short-lived state | `ChannelPlugin.connector` backed by package-local `connect.py` |
|
||||||
|
| Reusable local login-state detection | `ChannelPlugin.management.local_state_present` backed by package-local code |
|
||||||
|
| Discovery metadata and lazy runtime target | `PLUGIN` in `manifest.py` |
|
||||||
|
| WebUI structure, components, URLs, field keys, actions, and preset values | `webui/index.ts` or `webui/index.tsx` |
|
||||||
|
| Channel-specific user-facing copy | `webui/locales/<locale>.json` |
|
||||||
|
| Generic settings-shell copy shared by every channel | `webui/src/i18n/locales/<locale>/common.json` |
|
||||||
|
|
||||||
|
Keep one source of truth for each concern. In particular, the backend setup contract decides what may be written, the TypeScript contribution decides how those fields are presented, and locale JSON supplies the channel-specific words shown to users.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
nanobot/channels/webhook/
|
||||||
|
├── __init__.py # lightweight package marker; do not import the runtime
|
||||||
|
├── manifest.py # dependency-free ChannelPlugin descriptor
|
||||||
|
├── runtime.py # channel implementation and optional SDK imports
|
||||||
|
├── tests/ # package-local tests
|
||||||
|
└── webui/ # optional settings UI and translations
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Create Your Channel
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nanobot/channels/webhook/__init__.py
|
||||||
|
"""Webhook channel package."""
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nanobot/channels/webhook/manifest.py
|
||||||
|
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec
|
||||||
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN = ChannelPlugin(
|
||||||
|
name="webhook",
|
||||||
|
display_name="Webhook",
|
||||||
|
runtime=f"{__package__}.runtime:WebhookChannel",
|
||||||
|
dependencies=("aiohttp>=3.9.0,<4.0.0",),
|
||||||
|
setup=ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||||
|
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nanobot/channels/webhook/runtime.py
|
||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookConfig(Base):
|
||||||
|
"""Webhook channel configuration."""
|
||||||
|
enabled: bool = False
|
||||||
|
port: int = 9000
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookChannel(BaseChannel):
|
||||||
|
name = "webhook"
|
||||||
|
display_name = "Webhook"
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WebhookConfig(**config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_config(cls) -> dict[str, Any]:
|
||||||
|
return WebhookConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start an HTTP server that listens for incoming messages.
|
||||||
|
|
||||||
|
IMPORTANT: start() must block forever (or until stop() is called).
|
||||||
|
If it returns, the channel is considered dead.
|
||||||
|
"""
|
||||||
|
self._running = True
|
||||||
|
port = self.config.port
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/message", self._on_request)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||||
|
await site.start()
|
||||||
|
logger.info("Webhook listening on :{}", port)
|
||||||
|
|
||||||
|
# Block until stopped
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
await runner.cleanup()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
"""Deliver an outbound message.
|
||||||
|
|
||||||
|
msg.content — markdown text (convert to platform format as needed)
|
||||||
|
msg.media — list of local file paths to attach
|
||||||
|
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||||
|
msg.metadata — channel routing context such as message/thread ids
|
||||||
|
msg.event — typed runtime event for progress/status messages
|
||||||
|
"""
|
||||||
|
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||||
|
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||||
|
|
||||||
|
async def _on_request(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle an incoming HTTP POST."""
|
||||||
|
body = await request.json()
|
||||||
|
sender = body.get("sender", "unknown")
|
||||||
|
chat_id = body.get("chat_id", sender)
|
||||||
|
text = body.get("text", "")
|
||||||
|
media = body.get("media", []) # list of URLs
|
||||||
|
|
||||||
|
# This is the key call: validates allowFrom, then puts the
|
||||||
|
# message onto the bus for the agent to process.
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=sender,
|
||||||
|
chat_id=chat_id,
|
||||||
|
content=text,
|
||||||
|
media=media,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
```
|
||||||
|
|
||||||
|
The package directory, `PLUGIN.name`, runtime class name, and config section must all use `webhook`. Channel names use a portable ASCII package identifier: they start with a letter and contain only letters, digits, or underscores.
|
||||||
|
|
||||||
|
Declare runtime requirements directly in `ChannelPlugin.dependencies`. Do not add channel requirements to the root `pyproject.toml`: the package manifest is the source of truth used by the CLI, WebUI, and gateway startup. Keep the manifest and anything it imports free of the optional SDK itself.
|
||||||
|
|
||||||
|
### 2. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins list # verify the channel package appears as "webhook"
|
||||||
|
nanobot onboard # add default config for detected channels
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `~/.nanobot/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"webhook": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 9000,
|
||||||
|
"allowFrom": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
nanobot always loads the dependency-free descriptor during discovery. When the WebUI gateway starts, it installs missing requirements for enabled channels before importing their runtimes. It also installs them when a channel is enabled from the CLI or WebUI. Status, configuration, and disable operations do not need the runtime. Single-instance and multi-instance channels use the same activation rules.
|
||||||
|
|
||||||
|
### 3. Run & Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:9000/message \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent receives the message and processes it. Replies arrive in your `send()` method.
|
||||||
|
|
||||||
|
## Channel Package Requirements
|
||||||
|
|
||||||
|
Every channel is a self-contained package at `nanobot/channels/<channel>/`; channel-specific runtime code, setup metadata, tests, WebUI structure, components, and translations stay under that directory.
|
||||||
|
|
||||||
|
### Package Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
nanobot/channels/<channel>/
|
||||||
|
├── __init__.py # package marker only; no runtime or SDK imports
|
||||||
|
├── manifest.py # dependency-free ChannelPlugin and ChannelSetupSpec
|
||||||
|
├── config.py # optional dependency-free config model and defaults
|
||||||
|
├── connect.py # optional interactive setup connector
|
||||||
|
├── instances.py # optional dependency-free multi-instance management adapter
|
||||||
|
├── state.py # optional persisted login-state detection
|
||||||
|
├── validation.py # optional package-owned setup checks
|
||||||
|
├── runtime.py # BaseChannel implementation and platform SDK imports
|
||||||
|
├── tests/ # channel-specific Python tests
|
||||||
|
└── webui/ # optional, compiled into the shared WebUI
|
||||||
|
├── index.ts or index.tsx # structure and optional React components
|
||||||
|
└── locales/
|
||||||
|
├── en.json # canonical locale shape
|
||||||
|
└── <locale>.json # one file for every supported WebUI locale
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not add a runtime module directly under `nanobot/channels/`, create a parallel manifest tree, or add a central per-channel UI catalog. If existing channel files move, use `git mv` so history remains traceable.
|
||||||
|
|
||||||
|
### Manifest and Runtime Boundary
|
||||||
|
|
||||||
|
`manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker.
|
||||||
|
|
||||||
|
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, optional dependency extra, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
|
||||||
|
|
||||||
|
Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection.
|
||||||
|
|
||||||
|
Use the small constructors in [`nanobot/channels/_manifest.py`](../nanobot/channels/_manifest.py) for declarative field and requirement definitions. Use [`nanobot/channels/dingtalk/manifest.py`](../nanobot/channels/dingtalk/manifest.py) as a compact single-instance example and [`nanobot/channels/feishu/`](../nanobot/channels/feishu/) as a multi-instance example.
|
||||||
|
|
||||||
|
### Package-owned WebUI
|
||||||
|
|
||||||
|
Set `webui="webui/index.ts"` or `webui="webui/index.tsx"` in the channel manifest. Candidate modules are bundled from channel packages, but the settings UI activates only the exact path returned by the backend feature payload.
|
||||||
|
|
||||||
|
The entry module exports one default `ChannelUiContribution`. Channel identity comes from the package directory, so do not repeat a `channel` field in TypeScript. Keep only structure and executable UI data in this module: presentation metadata, icons or logo URLs, docs URLs, config field keys, action payloads, preset values, aliases, and optional `Panel` or `ConnectFlow` components.
|
||||||
|
|
||||||
|
Do not put static descriptions, setup steps, labels, placeholders, help text, action labels, or preset labels in TSX. Those strings belong in the channel's locale JSON. TSX remains appropriate for dynamic rendering, interpolation, conditions, and rich component composition.
|
||||||
|
|
||||||
|
### Channel-owned i18n
|
||||||
|
|
||||||
|
Create `webui/locales/<locale>.json` for every locale code declared in [`webui/src/i18n/config.ts`](../webui/src/i18n/config.ts). Treat `en.json` as the canonical shape; every other locale must contain the same message keys and the same interpolation variables. `displayName` may be omitted when the product name should remain unchanged.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "Use nanobot from Example chats.",
|
||||||
|
"requirements": "Example app credentials and gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Open Example setup",
|
||||||
|
"officialLabel": "Open Example console",
|
||||||
|
"summary": "Example needs app credentials.",
|
||||||
|
"tryIt": "Send a test message.",
|
||||||
|
"steps": [
|
||||||
|
"Create an Example app.",
|
||||||
|
"Add the credentials.",
|
||||||
|
"Save, enable, and test the channel."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "Example client ID",
|
||||||
|
"help": "Copy it from the Example console."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"copyManifest": "Copy manifest"
|
||||||
|
},
|
||||||
|
"presets": {
|
||||||
|
"default": "Default"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"custom": {
|
||||||
|
"connected": "{{name}} is connected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field messages are keyed by the config path after `channels.<channel>.`, with remaining punctuation converted to underscores. For example, `channels.signal.dm.allowFrom` maps to `setup.fields.dm_allowFrom`. Action and preset messages use the IDs declared in the TypeScript contribution.
|
||||||
|
|
||||||
|
Custom channel components should read dynamic copy with `channelTranslator(t, "<channel>")`; keep the English fallback adjacent to the call so an incomplete translation still renders useful text. Aliases reuse the owning channel's locale namespace rather than duplicating translations.
|
||||||
|
|
||||||
|
The dependency direction is intentional:
|
||||||
|
|
||||||
|
- [`webui/src/i18n/index.ts`](../webui/src/i18n/index.ts) imports the pure JSON [`channel-plugins/locale-registry.ts`](../webui/src/channel-plugins/locale-registry.ts).
|
||||||
|
- The locale registry discovers only `nanobot/channels/*/webui/locales/*.json` and must not import the UI registry, React, or TSX.
|
||||||
|
- Settings components may consume both the UI registry and locale registry.
|
||||||
|
- Channel UI code may use shared types and generic settings components, but core settings code must not add `if (feature.name === "...")` branches for individual channels.
|
||||||
|
|
||||||
|
This separation prevents i18n initialization from eagerly loading every channel React component and keeps channel-specific ownership below the channel package.
|
||||||
|
|
||||||
|
### Tests and Definition of Done
|
||||||
|
|
||||||
|
Put channel-specific Python tests in `nanobot/channels/<channel>/tests/`. Keep only shared registry, manager, base-class, and cross-channel contract tests in `tests/channels/`. Release builds exclude package-local tests while the repository test configuration discovers both trees.
|
||||||
|
|
||||||
|
For a focused channel change, run the smallest relevant set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest nanobot/channels/<channel>/tests -q
|
||||||
|
|
||||||
|
cd webui
|
||||||
|
bun run test -- src/tests/channel-locale-registry.test.ts src/tests/channel-ui-registry.test.ts src/tests/channel-identity.test.ts
|
||||||
|
bun run lint
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Before considering the change complete, verify all of the following:
|
||||||
|
|
||||||
|
- The manifest can be discovered without importing the runtime or optional platform SDK.
|
||||||
|
- `ChannelSetupSpec` contains every writable field and rejects unknown fields.
|
||||||
|
- The TypeScript field, action, and preset IDs have matching English locale messages.
|
||||||
|
- Every supported locale matches the English key shape and interpolation variables.
|
||||||
|
- Generic settings copy remains in core `common.json`; channel-specific copy remains inside the channel package.
|
||||||
|
- User-facing WebUI changes work through the built frontend served by a real gateway, including language switching and refresh persistence.
|
||||||
|
- Markdown prose paragraphs and individual list items remain on one source line; let the renderer handle visual wrapping.
|
||||||
|
|
||||||
|
## BaseChannel API
|
||||||
|
|
||||||
|
### Required (abstract)
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
|
||||||
|
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
|
||||||
|
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. Raise when the transport does not accept it. |
|
||||||
|
|
||||||
|
#### Outbound delivery contract
|
||||||
|
|
||||||
|
A normal return from `send()` means either the visible payload was accepted by the platform transport/API, or the channel deliberately had nothing to deliver, such as an empty progress event. Do not log and return when the client is disconnected, still starting, or the platform rejects the request. Raise an exception so `ChannelManager` can apply the shared retry policy.
|
||||||
|
|
||||||
|
`send()` may run as soon as `is_running` becomes true. If a channel sets `_running` before its transport is ready, it must keep raising until delivery can be attempted safely. Small platform-specific retries are fine, but the final failure must still reach the manager.
|
||||||
|
|
||||||
|
### Interactive Login
|
||||||
|
|
||||||
|
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def login(self, force: bool = False) -> bool:
|
||||||
|
"""
|
||||||
|
Perform channel-specific interactive login.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
force: If True, ignore existing credentials and re-authenticate.
|
||||||
|
|
||||||
|
Returns True if already authenticated or login succeeds.
|
||||||
|
"""
|
||||||
|
# For QR-code-based login:
|
||||||
|
# 1. If force, clear saved credentials
|
||||||
|
# 2. Check if already authenticated (load from disk/state)
|
||||||
|
# 3. If not, show QR code and poll for confirmation
|
||||||
|
# 4. Save token on success
|
||||||
|
```
|
||||||
|
|
||||||
|
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
|
||||||
|
|
||||||
|
Users trigger interactive login via:
|
||||||
|
```bash
|
||||||
|
nanobot channels login <channel_name>
|
||||||
|
nanobot channels login <channel_name> --force # re-authenticate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provided by Base
|
||||||
|
|
||||||
|
| Method / Property | Description |
|
||||||
|
|-------------------|-------------|
|
||||||
|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
||||||
|
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
||||||
|
| `default_config()` (classmethod) | Returns runtime-local defaults for callers that construct the class directly. Discovery and onboarding use the descriptor instead. |
|
||||||
|
| `refresh_feature_metadata(config_path, instance_id)` (classmethod) | Optionally refreshes saved display metadata after an explicit settings action. It is never called by a read-only feature GET. |
|
||||||
|
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
|
||||||
|
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||||
|
| `is_running` | Returns `self._running`. |
|
||||||
|
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||||
|
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||||
|
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||||
|
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||||
|
|
||||||
|
### Optional management contract
|
||||||
|
|
||||||
|
Persisted-state management belongs to `ChannelPlugin.management`, not `BaseChannel`. Keep the adapter and anything it imports free of optional platform SDKs so status, settings, and disable operations still work when the runtime cannot be imported. Runtime classes own network lifecycle, message delivery, interactive login, enable-time availability checks, and explicit runtime-only actions such as metadata refresh.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec, SetupRequirement
|
||||||
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
|
|
||||||
|
from .instances import MANAGEMENT
|
||||||
|
|
||||||
|
PLUGIN = ChannelPlugin(
|
||||||
|
name="webhook",
|
||||||
|
display_name="Webhook",
|
||||||
|
runtime=f"{__package__}.channel:WebhookChannel",
|
||||||
|
setup=ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": ChannelFieldSpec(kind="secret"),
|
||||||
|
"region": ChannelFieldSpec(
|
||||||
|
kind="enum",
|
||||||
|
choices=frozenset({"us", "eu"}),
|
||||||
|
default="us",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
required=(SetupRequirement.field("token"),),
|
||||||
|
),
|
||||||
|
management=MANAGEMENT,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`instances.py` then exports the dependency-free adapter assembled from channel-owned callbacks:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
|
||||||
|
|
||||||
|
from .config import default_config
|
||||||
|
|
||||||
|
|
||||||
|
def instance_specs(section: Any, *, enabled_only: bool = True) -> list[ChannelInstanceSpec]:
|
||||||
|
... # Expand the persisted channel-owned envelope.
|
||||||
|
|
||||||
|
|
||||||
|
def update_instance_config(
|
||||||
|
section: Any,
|
||||||
|
values: dict[str, Any],
|
||||||
|
*,
|
||||||
|
instance_id: str = "default",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
... # Update one instance without discarding sibling data.
|
||||||
|
|
||||||
|
|
||||||
|
MANAGEMENT = ChannelManagementSpec(
|
||||||
|
multi_instance=True,
|
||||||
|
default_config=default_config,
|
||||||
|
instance_specs=instance_specs,
|
||||||
|
update_instance_config=update_instance_config,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChannelSetupSpec` is authoritative for writable field names, field types, choices, defaults, required setup, secret redaction, and optional backend validation. The settings API rejects fields outside this contract. A validator receives `(values, context)`; use `context.allow_local_service_access` for host network policy instead of loading global config from the channel package.
|
||||||
|
|
||||||
|
The dependency-free `MANAGEMENT` value is a `ChannelManagementSpec`. Multi-instance plugins provide `instance_specs(section, enabled_only=True)` and `update_instance_config(section, values, instance_id=...)`; they may also provide `default_config`, `runtime_name`, presentation-only `feature_instances`, and `local_state_present`. Single-instance plugins normally derive onboarding defaults from `ChannelSetupSpec`; use `default_config` only when persisted defaults include fields that are not part of generic setup.
|
||||||
|
|
||||||
|
Multi-instance adapters return `ChannelInstanceSpec` objects and preserve their persisted envelope when updating one instance. Their descriptor sets `ChannelManagementSpec(multi_instance=True)`. The shared contract enforces these invariants:
|
||||||
|
|
||||||
|
- every `instance_id` is non-empty and unique;
|
||||||
|
- the management adapter's `runtime_name(channel_name, instance_id)` is the single source of routing names, and every derived name is unique and is either the channel name or starts with `<channel-name>.`;
|
||||||
|
- runtime names cannot overwrite a runtime already owned by another channel;
|
||||||
|
- settings instance summaries are generated from `instance_specs()` and `ChannelPlugin.setup`. They contain the authoritative `enabled` and `configured` state plus secret-safe `config_values` and `configured_fields` for the generic instance editor;
|
||||||
|
- the management adapter's `feature_instances()` may return `None` or presentation overrides containing an `id` plus `name`, `display_name`, or `avatar_url`. It cannot override runtime state or the configuration snapshot.
|
||||||
|
|
||||||
|
`ChannelInstanceSpec` contains only `instance_id` and the instance config; nanobot derives its runtime name through the adapter. Single-instance plugins keep ownership of their entire config, including a field named `instances`. Only plugins whose management spec sets `multi_instance=True` opt into instance expansion.
|
||||||
|
|
||||||
|
The package/config section name owns every runtime produced from that section. Class inheritance does not transfer runtime ownership to another package.
|
||||||
|
|
||||||
|
Return a concrete iterable or generator from the adapter's `instance_specs()`; nanobot materializes and validates it before constructing any runtime. Raise an exception for malformed persisted data rather than silently changing instance identity. Keep network-backed metadata refresh behind the runtime's `refresh_feature_metadata()` so feature GET requests remain dependency-free and read-only.
|
||||||
|
|
||||||
|
For package layout, WebUI ownership, and localization rules, see [Channel Package Requirements](#channel-package-requirements).
|
||||||
|
|
||||||
|
### Optional (streaming)
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||||
|
|
||||||
|
### Message Types
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class OutboundMessage:
|
||||||
|
channel: str # your channel name
|
||||||
|
chat_id: str # recipient (same value you passed to _handle_message)
|
||||||
|
content: str # markdown text — convert to platform format as needed
|
||||||
|
media: list[str] # local file paths to attach (images, audio, docs)
|
||||||
|
metadata: dict # channel routing context, e.g. "message_id" for threading
|
||||||
|
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
||||||
|
|
||||||
|
## Streaming Support
|
||||||
|
|
||||||
|
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
When **both** conditions are met, the agent streams content through your channel:
|
||||||
|
|
||||||
|
1. Config has `"streaming": true`
|
||||||
|
2. Your subclass overrides `send_delta()`
|
||||||
|
|
||||||
|
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
||||||
|
|
||||||
|
### Implementing `send_delta`
|
||||||
|
|
||||||
|
Override `send_delta` to handle two types of calls:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def send_delta(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
delta: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
stream_id: str | None = None,
|
||||||
|
stream_end: bool = False,
|
||||||
|
resuming: bool = False,
|
||||||
|
) -> None:
|
||||||
|
buffer_key = stream_id or chat_id
|
||||||
|
if stream_end:
|
||||||
|
# Streaming finished — do final formatting, cleanup, etc.
|
||||||
|
return
|
||||||
|
|
||||||
|
# Regular delta — append text, update the message on screen
|
||||||
|
# delta contains a small chunk of text (a few tokens)
|
||||||
|
```
|
||||||
|
|
||||||
|
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
||||||
|
|
||||||
|
### Example: Webhook with Streaming
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WebhookChannel(BaseChannel):
|
||||||
|
name = "webhook"
|
||||||
|
display_name = "Webhook"
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WebhookConfig(**config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self._buffers: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def send_delta(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
delta: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
stream_id: str | None = None,
|
||||||
|
stream_end: bool = False,
|
||||||
|
resuming: bool = False,
|
||||||
|
) -> None:
|
||||||
|
buffer_key = stream_id or chat_id
|
||||||
|
if stream_end:
|
||||||
|
text = self._buffers.pop(buffer_key, "")
|
||||||
|
# Final delivery — format and send the complete message
|
||||||
|
await self._deliver(chat_id, text, final=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._buffers.setdefault(buffer_key, "")
|
||||||
|
self._buffers[buffer_key] += delta
|
||||||
|
# Incremental update — push partial text to the client
|
||||||
|
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
# Non-streaming path — unchanged
|
||||||
|
await self._deliver(msg.chat_id, msg.content, final=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
Enable streaming per channel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"webhook": {
|
||||||
|
"enabled": true,
|
||||||
|
"streaming": true,
|
||||||
|
"allowFrom": ["*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
||||||
|
|
||||||
|
### BaseChannel Streaming API
|
||||||
|
|
||||||
|
| Method / Property | Description |
|
||||||
|
|-------------------|-------------|
|
||||||
|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
||||||
|
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||||
|
|
||||||
|
## Progress, Tool Hints, and Reasoning
|
||||||
|
|
||||||
|
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||||
|
|
||||||
|
### Progress and Tool Hints
|
||||||
|
|
||||||
|
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nanobot.bus.outbound_events import ProgressEvent
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
event = msg.event
|
||||||
|
|
||||||
|
if isinstance(event, ProgressEvent) and event.tool_hint:
|
||||||
|
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||||
|
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(event, ProgressEvent):
|
||||||
|
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||||
|
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"sendToolHints": true,
|
||||||
|
"webhook": {
|
||||||
|
"enabled": true,
|
||||||
|
"sendToolHints": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reasoning Blocks
|
||||||
|
|
||||||
|
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WebhookChannel(BaseChannel):
|
||||||
|
name = "webhook"
|
||||||
|
display_name = "Webhook"
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WebhookConfig(**config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self._reasoning_buffers: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def send_reasoning_delta(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
delta: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
stream_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
buffer_key = stream_id or chat_id
|
||||||
|
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
||||||
|
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
||||||
|
|
||||||
|
async def send_reasoning_end(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
*,
|
||||||
|
stream_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
buffer_key = stream_id or chat_id
|
||||||
|
text = self._reasoning_buffers.pop(buffer_key, "")
|
||||||
|
if text:
|
||||||
|
await self._update_reasoning_block(chat_id, text, final=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reasoning arguments:**
|
||||||
|
|
||||||
|
| Argument | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
||||||
|
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||||
|
| `send_reasoning_end()` | The current reasoning block is complete. |
|
||||||
|
|
||||||
|
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"showReasoning": true,
|
||||||
|
"webhook": {
|
||||||
|
"enabled": true,
|
||||||
|
"showReasoning": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended rendering:
|
||||||
|
|
||||||
|
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||||
|
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||||
|
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
### Why Pydantic model is required
|
||||||
|
|
||||||
|
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
|
||||||
|
|
||||||
|
Channel runtimes use Pydantic config models by subclassing `Base` from `nanobot.config.schema`.
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import Field
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
class WebhookConfig(Base):
|
||||||
|
"""Webhook channel configuration."""
|
||||||
|
enabled: bool = False
|
||||||
|
port: int = 9000
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
|
||||||
|
|
||||||
|
2. Convert `dict` → model in `__init__`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Any
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
class WebhookChannel(BaseChannel):
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WebhookConfig(**config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Access config as attributes (not `.get()`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def start(self) -> None:
|
||||||
|
port = self.config.port
|
||||||
|
token = self.config.token
|
||||||
|
```
|
||||||
|
|
||||||
|
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
||||||
|
|
||||||
|
`nanobot onboard` reads the descriptor without importing the runtime. Put writable defaults in `ChannelSetupSpec`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
setup=ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"port": ChannelFieldSpec(kind="int", default=9000),
|
||||||
|
"allowFrom": ChannelFieldSpec(kind="list"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
String and secret fields default to `""`, list fields to `[]`, and boolean fields to `false` when no explicit default is declared. For non-setup or multi-instance persisted defaults, provide `ChannelManagementSpec.default_config` from a dependency-free package-local module.
|
||||||
|
|
||||||
|
## Naming Convention
|
||||||
|
|
||||||
|
| What | Format | Example |
|
||||||
|
|------|--------|---------|
|
||||||
|
| Package directory | `nanobot/channels/{name}` | `nanobot/channels/webhook` |
|
||||||
|
| Manifest name | `{name}` | `webhook` |
|
||||||
|
| Config section | `channels.{name}` | `channels.webhook` |
|
||||||
|
| Runtime import | `nanobot.channels.{name}.runtime` | `nanobot.channels.webhook.runtime` |
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/HKUDS/nanobot.git
|
||||||
|
cd nanobot
|
||||||
|
python -m pip install -e .
|
||||||
|
nanobot plugins list # should show the package as "webhook"
|
||||||
|
nanobot gateway # test end-to-end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ nanobot plugins list
|
||||||
|
|
||||||
|
Name Type Enabled
|
||||||
|
discord channel no
|
||||||
|
telegram channel yes
|
||||||
|
webhook channel yes
|
||||||
|
```
|
||||||
@@ -1,568 +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. |
|
|
||||||
|
|
||||||
### Interactive Login
|
|
||||||
|
|
||||||
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def login(self, force: bool = False) -> bool:
|
|
||||||
"""
|
|
||||||
Perform channel-specific interactive login.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
force: If True, ignore existing credentials and re-authenticate.
|
|
||||||
|
|
||||||
Returns True if already authenticated or login succeeds.
|
|
||||||
"""
|
|
||||||
# For QR-code-based login:
|
|
||||||
# 1. If force, clear saved credentials
|
|
||||||
# 2. Check if already authenticated (load from disk/state)
|
|
||||||
# 3. If not, show QR code and poll for confirmation
|
|
||||||
# 4. Save token on success
|
|
||||||
```
|
|
||||||
|
|
||||||
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
|
|
||||||
|
|
||||||
Users trigger interactive login via:
|
|
||||||
```bash
|
|
||||||
nanobot channels login <channel_name>
|
|
||||||
nanobot channels login <channel_name> --force # re-authenticate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Provided by Base
|
|
||||||
|
|
||||||
| Method / Property | Description |
|
|
||||||
|-------------------|-------------|
|
|
||||||
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
|
|
||||||
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
|
|
||||||
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
|
|
||||||
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
|
|
||||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
|
||||||
| `is_running` | Returns `self._running`. |
|
|
||||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
|
||||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
|
||||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
|
||||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
|
||||||
|
|
||||||
### Optional (streaming)
|
|
||||||
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
|
||||||
|
|
||||||
### Message Types
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class OutboundMessage:
|
|
||||||
channel: str # your channel name
|
|
||||||
chat_id: str # recipient (same value you passed to _handle_message)
|
|
||||||
content: str # markdown text — convert to platform format as needed
|
|
||||||
media: list[str] # local file paths to attach (images, audio, docs)
|
|
||||||
metadata: dict # channel routing context, e.g. "message_id" for threading
|
|
||||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
|
||||||
```
|
|
||||||
|
|
||||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
|
||||||
|
|
||||||
## Streaming Support
|
|
||||||
|
|
||||||
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
When **both** conditions are met, the agent streams content through your channel:
|
|
||||||
|
|
||||||
1. Config has `"streaming": true`
|
|
||||||
2. Your subclass overrides `send_delta()`
|
|
||||||
|
|
||||||
If either is missing, the agent falls back to the normal one-shot `send()` path.
|
|
||||||
|
|
||||||
### Implementing `send_delta`
|
|
||||||
|
|
||||||
Override `send_delta` to handle two types of calls:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def send_delta(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
buffer_key = stream_id or chat_id
|
|
||||||
if stream_end:
|
|
||||||
# Streaming finished — do final formatting, cleanup, etc.
|
|
||||||
return
|
|
||||||
|
|
||||||
# Regular delta — append text, update the message on screen
|
|
||||||
# delta contains a small chunk of text (a few tokens)
|
|
||||||
```
|
|
||||||
|
|
||||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
|
||||||
|
|
||||||
### Example: Webhook with Streaming
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WebhookChannel(BaseChannel):
|
|
||||||
name = "webhook"
|
|
||||||
display_name = "Webhook"
|
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config = WebhookConfig(**config)
|
|
||||||
super().__init__(config, bus)
|
|
||||||
self._buffers: dict[str, str] = {}
|
|
||||||
|
|
||||||
async def send_delta(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
buffer_key = stream_id or chat_id
|
|
||||||
if stream_end:
|
|
||||||
text = self._buffers.pop(buffer_key, "")
|
|
||||||
# Final delivery — format and send the complete message
|
|
||||||
await self._deliver(chat_id, text, final=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._buffers.setdefault(buffer_key, "")
|
|
||||||
self._buffers[buffer_key] += delta
|
|
||||||
# Incremental update — push partial text to the client
|
|
||||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
|
||||||
# Non-streaming path — unchanged
|
|
||||||
await self._deliver(msg.chat_id, msg.content, final=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Config
|
|
||||||
|
|
||||||
Enable streaming per channel:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"webhook": {
|
|
||||||
"enabled": true,
|
|
||||||
"streaming": true,
|
|
||||||
"allowFrom": ["*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
|
|
||||||
|
|
||||||
### BaseChannel Streaming API
|
|
||||||
|
|
||||||
| Method / Property | Description |
|
|
||||||
|-------------------|-------------|
|
|
||||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
|
||||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
|
||||||
|
|
||||||
## Progress, Tool Hints, and Reasoning
|
|
||||||
|
|
||||||
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
|
||||||
|
|
||||||
### Progress and Tool Hints
|
|
||||||
|
|
||||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
|
||||||
event = msg.event
|
|
||||||
|
|
||||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
|
||||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
|
||||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(event, ProgressEvent):
|
|
||||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
|
||||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
|
||||||
```
|
|
||||||
|
|
||||||
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"sendToolHints": true,
|
|
||||||
"webhook": {
|
|
||||||
"enabled": true,
|
|
||||||
"sendToolHints": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Reasoning Blocks
|
|
||||||
|
|
||||||
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class WebhookChannel(BaseChannel):
|
|
||||||
name = "webhook"
|
|
||||||
display_name = "Webhook"
|
|
||||||
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config = WebhookConfig(**config)
|
|
||||||
super().__init__(config, bus)
|
|
||||||
self._reasoning_buffers: dict[str, str] = {}
|
|
||||||
|
|
||||||
async def send_reasoning_delta(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
buffer_key = stream_id or chat_id
|
|
||||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
|
||||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
|
||||||
|
|
||||||
async def send_reasoning_end(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
buffer_key = stream_id or chat_id
|
|
||||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
|
||||||
if text:
|
|
||||||
await self._update_reasoning_block(chat_id, text, final=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Reasoning arguments:**
|
|
||||||
|
|
||||||
| Argument | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
|
||||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
|
||||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
|
||||||
|
|
||||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"channels": {
|
|
||||||
"showReasoning": true,
|
|
||||||
"webhook": {
|
|
||||||
"enabled": true,
|
|
||||||
"showReasoning": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Recommended rendering:
|
|
||||||
|
|
||||||
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
|
||||||
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
|
||||||
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
### Why Pydantic model is required
|
|
||||||
|
|
||||||
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`** — `dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
|
|
||||||
|
|
||||||
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
|
|
||||||
|
|
||||||
### Pattern
|
|
||||||
|
|
||||||
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pydantic import Field
|
|
||||||
from nanobot.config.schema import Base
|
|
||||||
|
|
||||||
class WebhookConfig(Base):
|
|
||||||
"""Webhook channel configuration."""
|
|
||||||
enabled: bool = False
|
|
||||||
port: int = 9000
|
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
|
|
||||||
|
|
||||||
2. Convert `dict` → model in `__init__`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Any
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
|
|
||||||
class WebhookChannel(BaseChannel):
|
|
||||||
def __init__(self, config: Any, bus: MessageBus):
|
|
||||||
if isinstance(config, dict):
|
|
||||||
config = WebhookConfig(**config)
|
|
||||||
super().__init__(config, bus)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Access config as attributes (not `.get()`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def start(self) -> None:
|
|
||||||
port = self.config.port
|
|
||||||
token = self.config.token
|
|
||||||
```
|
|
||||||
|
|
||||||
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
|
|
||||||
|
|
||||||
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@classmethod
|
|
||||||
def default_config(cls) -> dict[str, Any]:
|
|
||||||
return WebhookConfig().model_dump(by_alias=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
|
|
||||||
|
|
||||||
If not overridden, the base class returns `{"enabled": false}`.
|
|
||||||
|
|
||||||
## Naming Convention
|
|
||||||
|
|
||||||
| What | Format | Example |
|
|
||||||
|------|--------|---------|
|
|
||||||
| PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
|
|
||||||
| Entry point key | `{name}` | `webhook` |
|
|
||||||
| Config section | `channels.{name}` | `channels.webhook` |
|
|
||||||
| Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/you/nanobot-channel-webhook
|
|
||||||
cd nanobot-channel-webhook
|
|
||||||
python -m pip install -e .
|
|
||||||
nanobot plugins list # should show the installed example plugin as "webhook"
|
|
||||||
nanobot gateway # test end-to-end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ nanobot plugins list
|
|
||||||
|
|
||||||
Name Type Enabled
|
|
||||||
discord channel no
|
|
||||||
telegram channel yes
|
|
||||||
webhook channel yes
|
|
||||||
```
|
|
||||||
+25
-7
@@ -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) |
|
| 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) |
|
| 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:
|
Before configuring a chat app, make sure the local CLI path works:
|
||||||
|
|
||||||
@@ -26,11 +26,23 @@ nanobot agent -m "Hello!"
|
|||||||
|
|
||||||
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
|
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
|
||||||
|
|
||||||
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a
|
## Recommended Setup in the WebUI
|
||||||
snippet includes `allowFrom`, it is showing a static allowlist. For
|
|
||||||
pairing-based access on supported channels, omit `allowFrom`; Slack and
|
For normal local setup, let the WebUI write and validate the channel config:
|
||||||
Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing
|
|
||||||
codes.
|
1. Run `nanobot webui`.
|
||||||
|
2. Open **Settings → Channels**.
|
||||||
|
3. Search for the platform and open its setup panel.
|
||||||
|
4. Follow the credential fields or QR flow. The screen tells you which platform-side token, permission, account, or URL it needs.
|
||||||
|
5. Let nanobot install the optional channel support when prompted.
|
||||||
|
6. Restart from the WebUI if it reports that a restart is required.
|
||||||
|
7. Send a private test message. If the channel returns a pairing code, approve the pending request in the WebUI and send the message again.
|
||||||
|
|
||||||
|
If your installed stable release does not show **Settings → Channels**, continue with the [manual setup pattern](#manual-setup-pattern) below or install current source.
|
||||||
|
|
||||||
|
Optional package installation is available to a same-machine WebUI by default. Remote browser clients cannot change the Python environment unless an administrator explicitly enables that capability. Run `nanobot plugins enable <channel>` locally when the guided install is unavailable.
|
||||||
|
|
||||||
|
The sections below explain what each chat platform requires and provide manual config for deployments that manage `config.json` directly.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If you are upgrading from a version where chat app SDKs were installed by default,
|
> If you are upgrading from a version where chat app SDKs were installed by default,
|
||||||
@@ -47,7 +59,9 @@ codes.
|
|||||||
> nanobot keeps the saved settings, but stops loading that channel after the
|
> nanobot keeps the saved settings, but stops loading that channel after the
|
||||||
> next restart.
|
> next restart.
|
||||||
|
|
||||||
## Common Setup Pattern
|
## Manual Setup Pattern
|
||||||
|
|
||||||
|
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a snippet includes `allowFrom`, it is showing a static allowlist. For pairing-based access on supported channels, omit `allowFrom`; Slack and Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing codes.
|
||||||
|
|
||||||
Every chat app uses the same shape:
|
Every chat app uses the same shape:
|
||||||
|
|
||||||
@@ -379,6 +393,10 @@ nanobot channels login whatsapp
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For groups, `allowFrom` can contain either a participant sender ID/LID or a
|
||||||
|
group JID/bare group ID. A participant entry allows that sender wherever the bot
|
||||||
|
can see them; a group entry allows replies in that group.
|
||||||
|
|
||||||
Optional session database path:
|
Optional session database path:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+2
-2
@@ -64,9 +64,9 @@ That flow is the same whether the message starts in the CLI, WebUI, Telegram, Di
|
|||||||
| CLI interactive | `nanobot agent` | Terminal chat with persistent session history |
|
| CLI interactive | `nanobot agent` | Terminal chat with persistent session history |
|
||||||
| Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode |
|
| Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode |
|
||||||
| OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` |
|
| OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` |
|
||||||
| WebUI | `nanobot gateway` plus WebSocket channel | Browser workbench served by the WebSocket channel on port `8765` |
|
| WebUI | `nanobot webui` | Prepare the local WebUI, start the gateway, and open the browser workbench |
|
||||||
|
|
||||||
The gateway health endpoint is on `gateway.port` (`18790` by default). The browser WebUI is served by the WebSocket channel (`8765` by default), not by the health endpoint.
|
The WebUI launcher is the normal browser entry point. Underneath, the gateway keeps the WebSocket channel and other long-running services alive. The gateway health endpoint is on `gateway.port` (`18790` by default); the browser WebUI is served on `8765` by default, not by the health endpoint.
|
||||||
|
|
||||||
## Provider and Model Selection
|
## Provider and Model Selection
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -4,6 +4,8 @@ Config file: `~/.nanobot/config.json`
|
|||||||
|
|
||||||
This is the full reference. If this is your first install, start with [`quick-start.md`](./quick-start.md). If you are trying to choose a model or fix provider/model matching, use [`providers.md`](./providers.md) first and come back here for exact fields and advanced options.
|
This is the full reference. If this is your first install, start with [`quick-start.md`](./quick-start.md). If you are trying to choose a model or fix provider/model matching, use [`providers.md`](./providers.md) first and come back here for exact fields and advanced options.
|
||||||
|
|
||||||
|
For normal local use, prefer the WebUI before editing JSON: **Settings → Models** manages model choices and provider credentials, **Settings → Channels** guides chat-platform setup, other Settings pages cover built-in capabilities, and **Apps** manages CLI App and MCP integrations. Edit `config.json` directly when you need an advanced field, automate deployment, or intentionally manage configuration as code.
|
||||||
|
|
||||||
The JSON examples below are usually partial snippets to merge into your existing config, not full replacement files. For the mental model behind config, workspace, gateway, channels, sessions, tools, and memory, see [`concepts.md`](./concepts.md).
|
The JSON examples below are usually partial snippets to merge into your existing config, not full replacement files. For the mental model behind config, workspace, gateway, channels, sessions, tools, and memory, see [`concepts.md`](./concepts.md).
|
||||||
|
|
||||||
The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`. snake_case keys are also accepted for compatibility, but the docs prefer camelCase because that is what nanobot writes back to disk.
|
The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`. snake_case keys are also accepted for compatibility, but the docs prefer camelCase because that is what nanobot writes back to disk.
|
||||||
@@ -11,7 +13,7 @@ The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`
|
|||||||
For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once.
|
For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
|
> If your config file is older than the current schema, run `nanobot onboard --refresh`. nanobot adds missing default fields while preserving your existing values.
|
||||||
|
|
||||||
## Configuration Guides
|
## Configuration Guides
|
||||||
|
|
||||||
@@ -47,9 +49,9 @@ the focused guides first and come back here for exact fields and defaults.
|
|||||||
| Control access and pairing | [Pairing](#pairing) |
|
| Control access and pairing | [Pairing](#pairing) |
|
||||||
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
|
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
|
||||||
|
|
||||||
## Where to Edit First
|
## Where a Setting Lives
|
||||||
|
|
||||||
If you are not sure where a setting belongs, start from the task you are trying to complete. Most changes touch one config section and one verification command.
|
If the WebUI does not expose the option you need, start from the task below. Most advanced changes touch one config section and one verification command.
|
||||||
|
|
||||||
| Task | First keys to check | Verify with | Deep dive |
|
| Task | First keys to check | Verify with | Deep dive |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -185,7 +187,7 @@ These variables are process-level switches. Set them in the same terminal, servi
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. |
|
| `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. |
|
||||||
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds, around ordinary LLM requests. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
|
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
|
||||||
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
|
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
|
||||||
| `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. |
|
| `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. |
|
||||||
| `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` | unset | Marks that an external workspace sandbox is already enforced. Truthy values (`1`, `true`, `yes`, `on`, `enabled`) use `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` as the label; any other non-false value is treated as the provider name. |
|
| `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` | unset | Marks that an external workspace sandbox is already enforced. Truthy values (`1`, `true`, `yes`, `on`, `enabled`) use `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` as the label; any other non-false value is treated as the provider name. |
|
||||||
@@ -1917,7 +1919,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
|||||||
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
||||||
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
||||||
|
|
||||||
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
|
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. The default `docker-compose.yml` drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable `"tools.exec.sandbox": "bwrap"` inside Docker, start Compose with `docker-compose.bwrap.yml` as an additional override so bubblewrap can create nested namespaces.
|
||||||
|
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
@@ -2029,6 +2031,10 @@ The heartbeat job is backed by the same cron service as user-created reminders.
|
|||||||
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
||||||
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
|
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
|
||||||
|
|
||||||
|
### Custom heartbeat evaluator prompt
|
||||||
|
|
||||||
|
The notification gate runs on a built-in system prompt. Advanced users can override it, but you rarely need to — it's strongly advised to first read the evaluator code and the default `evaluator.md`. To override, drop your prompt at `<workspace>/prompts/evaluator.md`. It must still instruct the model to call the `evaluate_notification` tool; otherwise the gate fails closed and stays silent.
|
||||||
|
|
||||||
|
|
||||||
## Subagent Concurrency
|
## Subagent Concurrency
|
||||||
|
|
||||||
|
|||||||
+25
-6
@@ -74,6 +74,20 @@ docker compose logs -f nanobot-gateway # view logs
|
|||||||
docker compose down # stop
|
docker compose down # stop
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The default Compose file drops all Linux capabilities and keeps Docker's default
|
||||||
|
AppArmor/seccomp profiles enabled. If you explicitly set
|
||||||
|
`"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap
|
||||||
|
override file when starting containers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanobot-gateway
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanobot-cli agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
The override grants `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for
|
||||||
|
the container so bubblewrap can create its nested namespaces. Use it only when the
|
||||||
|
bwrap sandbox is enabled.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -87,12 +101,17 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
|||||||
vim ~/.nanobot/config.json
|
vim ~/.nanobot/config.json
|
||||||
|
|
||||||
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
|
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
|
||||||
# Mirrors the security caps and port mappings declared in docker-compose.yml:
|
# `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway
|
||||||
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
|
# health endpoint on 18790.
|
||||||
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
|
docker run \
|
||||||
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
|
--cap-drop ALL \
|
||||||
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
|
-v ~/.nanobot:/home/nanobot/.nanobot \
|
||||||
# endpoint on 18790.
|
-p 18790:18790 -p 8765:8765 \
|
||||||
|
nanobot gateway
|
||||||
|
|
||||||
|
# If `tools.exec.sandbox: "bwrap"` is enabled, run with the extra permissions
|
||||||
|
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
|
||||||
|
# `clone3: Operation not permitted`.
|
||||||
docker run \
|
docker run \
|
||||||
--cap-drop ALL --cap-add SYS_ADMIN \
|
--cap-drop ALL --cap-add SYS_ADMIN \
|
||||||
--security-opt apparmor=unconfined \
|
--security-opt apparmor=unconfined \
|
||||||
|
|||||||
+15
-11
@@ -1,21 +1,20 @@
|
|||||||
# nanobot Guides
|
# nanobot Task Guides
|
||||||
|
|
||||||
These guides are short task entry points. Use them when you know what you want
|
Start with [Install and Quick Start](../quick-start.md) and get one reply before using a guide below. Each guide targets one outcome; linked reference pages hold the complete option tables and edge cases.
|
||||||
to build, then follow the linked reference docs for complete option tables and
|
|
||||||
edge cases.
|
|
||||||
|
|
||||||
## Build and operate
|
## Start and Use
|
||||||
|
|
||||||
| Goal | Guide |
|
| Goal | Guide |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Build a personal AI agent | [Build a personal AI agent](./build-a-personal-ai-agent.md) |
|
| Build a personal AI agent | [Build a personal AI agent](./build-a-personal-ai-agent.md) |
|
||||||
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
|
||||||
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
|
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
|
||||||
| Run long-running tasks | [Long-running AI agent](./long-running-ai-agent.md) |
|
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
||||||
| Add memory | [AI agent memory](./ai-agent-memory.md) |
|
| Run a sustained goal | [Long-running AI agent](./long-running-ai-agent.md) |
|
||||||
| Deploy a gateway | [Deploy a long-running nanobot AI agent gateway](./deploy-nanobot-gateway.md) |
|
| Add long-term memory | [AI agent memory](./ai-agent-memory.md) |
|
||||||
|
|
||||||
## Connect and integrate
|
## Connect a Chat App
|
||||||
|
|
||||||
|
Use **Settings → Channels** in the WebUI for guided setup. These guides explain the account, bot, token, permission, and test-message steps on each platform.
|
||||||
|
|
||||||
| Goal | Guide |
|
| Goal | Guide |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -29,10 +28,15 @@ edge cases.
|
|||||||
| Connect QQ | [QQ AI agent](./qq-ai-agent.md) |
|
| Connect QQ | [QQ AI agent](./qq-ai-agent.md) |
|
||||||
| Connect Email | [Email AI agent](./email-ai-agent.md) |
|
| Connect Email | [Email AI agent](./email-ai-agent.md) |
|
||||||
| Connect Mattermost | [Mattermost AI agent](./mattermost-ai-agent.md) |
|
| Connect Mattermost | [Mattermost AI agent](./mattermost-ai-agent.md) |
|
||||||
|
|
||||||
|
## Integrate from Code
|
||||||
|
|
||||||
|
| Goal | Guide |
|
||||||
|
|---|---|
|
||||||
| Run from Python | [Python AI agent SDK](./python-ai-agent-sdk.md) |
|
| Run from Python | [Python AI agent SDK](./python-ai-agent-sdk.md) |
|
||||||
| Expose `/v1/chat/completions` | [OpenAI-compatible agent API](./openai-compatible-agent-api.md) |
|
| Expose `/v1/chat/completions` | [OpenAI-compatible agent API](./openai-compatible-agent-api.md) |
|
||||||
|
|
||||||
## Configure
|
## Configure and Operate
|
||||||
|
|
||||||
| Goal | Guide |
|
| Goal | Guide |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ private DMs, team channels, group chats, email threads, or bot workspaces.
|
|||||||
```bash
|
```bash
|
||||||
python -m pip install nanobot-ai
|
python -m pip install nanobot-ai
|
||||||
nanobot onboard --wizard
|
nanobot onboard --wizard
|
||||||
nanobot agent -m "Hello!"
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
Then choose one platform guide:
|
Send `Hello!` in the WebUI before adding a channel. Then choose one platform guide for the bot/account prerequisites:
|
||||||
|
|
||||||
- [Telegram AI agent](./telegram-ai-agent.md)
|
- [Telegram AI agent](./telegram-ai-agent.md)
|
||||||
- [Discord AI agent](./discord-ai-agent.md)
|
- [Discord AI agent](./discord-ai-agent.md)
|
||||||
@@ -38,28 +38,31 @@ Then choose one platform guide:
|
|||||||
|
|
||||||
## Minimal working example
|
## Minimal working example
|
||||||
|
|
||||||
Every channel follows the same pattern:
|
Use the guided channel setup:
|
||||||
|
|
||||||
1. Get the platform token, login state, webhook, or mailbox credentials.
|
1. Get the platform token, login state, webhook, or mailbox credentials.
|
||||||
2. Merge the channel snippet into `~/.nanobot/config.json`.
|
2. Open **Settings → Channels** in the WebUI.
|
||||||
3. Prefer pairing for DM-capable channels: omit `allowFrom`, then approve the
|
3. Choose the platform and open its setup panel.
|
||||||
first DM's pairing code.
|
4. Complete the credential or QR flow and install optional support if prompted.
|
||||||
4. For channels without pairing, such as Email, keep access narrow with
|
5. Restart when the WebUI requests it.
|
||||||
`allowFrom` or platform-specific allow lists.
|
6. Send a private test message.
|
||||||
5. Check status:
|
7. Approve the pairing request in the WebUI when a DM-capable channel asks for one.
|
||||||
|
|
||||||
|
If your installed release does not show **Settings → Channels**, use the full [Chat Apps reference](../chat-apps.md#manual-setup-pattern) to configure the channel manually.
|
||||||
|
|
||||||
|
Check status from the terminal when you need a lower-level confirmation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot channels status
|
nanobot channels status
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Start the gateway:
|
The `nanobot webui` command already runs the gateway. For a chat-only or server deployment, start it directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Send a test DM, approve the pairing code when prompted, then send the test
|
Use the full [Chat Apps reference](../chat-apps.md) when you manage `config.json` directly or need platform-specific advanced settings.
|
||||||
message again.
|
|
||||||
|
|
||||||
## Production notes
|
## Production notes
|
||||||
|
|
||||||
@@ -80,8 +83,7 @@ nanobot gateway
|
|||||||
|
|
||||||
- If `nanobot channels status` does not show the channel, the config key or
|
- If `nanobot channels status` does not show the channel, the config key or
|
||||||
optional dependency is likely missing.
|
optional dependency is likely missing.
|
||||||
- If the first DM returns a pairing code, approve it with
|
- If the first DM returns a pairing code, approve the pending request in the WebUI or use `/pairing approve <code>` from an authorized chat.
|
||||||
`/pairing approve <code>` before expecting normal replies.
|
|
||||||
- If messages do not arrive, run `nanobot gateway --verbose` and compare
|
- If messages do not arrive, run `nanobot gateway --verbose` and compare
|
||||||
platform credentials, event permissions, and allow lists.
|
platform credentials, event permissions, and allow lists.
|
||||||
- If group replies are unexpected, review that channel's group policy.
|
- If group replies are unexpected, review that channel's group policy.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ through the Model Context Protocol.
|
|||||||
## What you will build
|
## What you will build
|
||||||
|
|
||||||
- a working nanobot agent
|
- a working nanobot agent
|
||||||
- one MCP server entry in `~/.nanobot/config.json`
|
- one MCP integration configured through Apps or `~/.nanobot/config.json`
|
||||||
- a restricted set of MCP tools exposed to the model
|
- a restricted set of MCP tools exposed to the model
|
||||||
|
|
||||||
## When to use this
|
## When to use this
|
||||||
@@ -27,7 +27,15 @@ remote HTTP endpoint.
|
|||||||
|
|
||||||
## Minimal working example
|
## Minimal working example
|
||||||
|
|
||||||
Add this to `~/.nanobot/config.json`:
|
For local interactive setup:
|
||||||
|
|
||||||
|
1. Run `nanobot webui` and open **Apps**.
|
||||||
|
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
|
||||||
|
3. Limit the enabled tools when the server exposes more than the task needs.
|
||||||
|
4. Save and restart when prompted.
|
||||||
|
5. Mention the integration with `@` in the next message and ask for a small test action.
|
||||||
|
|
||||||
|
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ providers.
|
|||||||
## What you will build
|
## What you will build
|
||||||
|
|
||||||
- web tools enabled in nanobot
|
- web tools enabled in nanobot
|
||||||
- one search provider selected in `config.json`
|
- one search provider selected in the WebUI or `config.json`
|
||||||
- optional web fetch settings for page reading
|
- optional web fetch settings for page reading
|
||||||
|
|
||||||
## When to use this
|
## When to use this
|
||||||
@@ -28,7 +28,15 @@ provider, API key, proxy, fetch behavior, or SSRF allowlist.
|
|||||||
|
|
||||||
## Minimal working example
|
## Minimal working example
|
||||||
|
|
||||||
Use the default search provider:
|
For local interactive setup:
|
||||||
|
|
||||||
|
1. Run `nanobot webui`.
|
||||||
|
2. Open **Settings → Web**.
|
||||||
|
3. Enable web search, choose a provider, and enter its API key if required.
|
||||||
|
4. Save and restart when prompted.
|
||||||
|
5. Ask a question that requires current information and inspect the cited sources.
|
||||||
|
|
||||||
|
For manual or deployment-managed config, use the default search provider:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
|
nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
|
||||||
|
|
||||||
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
|
The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, save, and restart when prompted. If that screen is not available in your installed version, use the manual config below.
|
||||||
|
|
||||||
## Quick Setup
|
## Quick Setup
|
||||||
|
|
||||||
|
**WebUI**
|
||||||
|
|
||||||
|
1. Add the image provider credential under **Settings → Models** if it is not already configured.
|
||||||
|
2. Open **Settings → Image**.
|
||||||
|
3. Select the provider and image model, then enable image generation.
|
||||||
|
4. Save, restart when prompted, and ask for a simple test image.
|
||||||
|
|
||||||
|
**Manual config**
|
||||||
|
|
||||||
This snippet uses the current built-in image-generation default so the JSON has concrete names. It is not a provider recommendation; replace `provider` and `model` with any supported image provider and model you intend to use.
|
This snippet uses the current built-in image-generation default so the JSON has concrete names. It is not a provider recommendation; replace `provider` and `model` with any supported image provider and model you intend to use.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md).
|
Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md).
|
||||||
|
|
||||||
|
For normal local setup, open **Settings → Models** in the WebUI to add provider credentials, create a model preset, and select the active model. Use the JSON below for manual deployments, local endpoints, provider-specific fields, or diagnosis.
|
||||||
|
|
||||||
For every setup, answer three questions:
|
For every setup, answer three questions:
|
||||||
|
|
||||||
1. Which provider owns the credential or endpoint?
|
1. Which provider owns the credential or endpoint?
|
||||||
|
|||||||
+170
-270
@@ -1,153 +1,197 @@
|
|||||||
# Install and Quick Start
|
# Install and Quick Start
|
||||||
|
|
||||||
This page gets one local nanobot reply working. After that, you can add the WebUI, chat apps, local models, web search, MCP, deployment, or custom plugins.
|
This guide has one goal: get a normal nanobot reply in your browser. Do not add chat apps, MCP servers, fallback models, or deployment until this path works.
|
||||||
|
|
||||||
If you have never used a terminal or edited a config file before, use [`start-without-technical-background.md`](./start-without-technical-background.md) first. This page assumes you are comfortable pasting commands and editing JSON snippets.
|
If terminals, Python, or API keys are unfamiliar, use the [beginner walkthrough](./start-without-technical-background.md), which explains each term and screen.
|
||||||
|
|
||||||
## Before You Start
|
These repository docs follow current `main`. The recommended installer uses the stable package, so a newly documented WebUI screen may not appear until the next release. Each advanced guide also provides a CLI or manual config path.
|
||||||
|
|
||||||
You need:
|
## What You Need
|
||||||
|
|
||||||
- Python 3.11 or newer.
|
- Python 3.11 or newer.
|
||||||
- One LLM provider, company endpoint, subscription endpoint, or local model server you can call. The examples below use a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service; any supported provider works when the key, provider name, and model ID match.
|
- Access to one supported AI provider, company endpoint, or local model server.
|
||||||
- Git only if you install from source.
|
- The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key.
|
||||||
- Node.js or Bun only if you are developing the WebUI itself.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
Git is only needed for a source install. The published package already contains the WebUI. A current-source install needs `bun` or `npm` so its WebUI bundle can be built.
|
||||||
> Repository docs may describe features that are available first in source. Install from PyPI or `uv` for the stable day-to-day release; install from source when you want the newest repository behavior or plan to contribute.
|
|
||||||
|
|
||||||
## 1. Install
|
## 1. Install nanobot
|
||||||
|
|
||||||
Pick one install method.
|
The recommended installer keeps nanobot out of the system Python environment and opens the setup wizard when installation finishes.
|
||||||
|
|
||||||
**One-command setup:**
|
**macOS / Linux**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||||
```
|
```
|
||||||
|
|
||||||
On Windows PowerShell:
|
**Windows PowerShell**
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, go straight to [Open the WebUI](#5-open-the-webui).
|
The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release unless you explicitly pass `--dev`. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below.
|
||||||
|
|
||||||
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
|
||||||
|
|
||||||
```bash
|
## 2. Complete Quick Start
|
||||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
The installer opens `nanobot onboard --wizard`. Choose **Quick Start** and follow the prompts:
|
||||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
To install the current `main` branch instead, pass `--dev`:
|
1. Choose the provider or endpoint that owns your credential.
|
||||||
|
2. Enter its API key or base URL when requested.
|
||||||
|
3. Enter a model ID that the same provider can run.
|
||||||
|
4. Let Quick Start enable the local WebUI.
|
||||||
|
5. Set a WebUI password and review the summary.
|
||||||
|
|
||||||
```bash
|
Quick Start creates or updates:
|
||||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
| Path | Purpose |
|
||||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
|---|---|
|
||||||
```
|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
||||||
|
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
|
||||||
|
|
||||||
If `curl` or `irm` is unavailable, or GitHub raw downloads are blocked on your network, use one of the manual install methods below.
|
If the installer did not open the wizard, run it yourself:
|
||||||
|
|
||||||
If you prefer to inspect the script first, open [`../scripts/install.sh`](../scripts/install.sh) or [`../scripts/install.ps1`](../scripts/install.ps1).
|
|
||||||
|
|
||||||
**Stable release with `uv`:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv tool install nanobot-ai
|
|
||||||
nanobot --version
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stable release with pip:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pip install nanobot-ai
|
|
||||||
nanobot --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Use pip only inside an environment you control. If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment first.
|
|
||||||
|
|
||||||
**Latest source checkout:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/HKUDS/nanobot.git
|
|
||||||
cd nanobot
|
|
||||||
python -m pip install -e .
|
|
||||||
nanobot --version
|
|
||||||
```
|
|
||||||
|
|
||||||
If your shell cannot find `nanobot` after a pip install, run the module form:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m nanobot --version
|
|
||||||
python -m nanobot onboard
|
|
||||||
```
|
|
||||||
|
|
||||||
On Windows, `~` in the docs means your user profile directory, for example `C:\Users\you`.
|
|
||||||
|
|
||||||
The docs use `python` in commands. If your system exposes Python 3.11+ as `python3` or `py`, use that command in the same place, for example `python3 -m pip install nanobot-ai` or `py -m nanobot --version`.
|
|
||||||
|
|
||||||
## 2. Initialize
|
|
||||||
|
|
||||||
Skip this section if the one-command setup already started the wizard and Quick Start finished there.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot onboard
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the wizard if you prefer prompts instead of editing JSON by hand:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot onboard --wizard
|
nanobot onboard --wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
Initialization creates:
|
Current source versions also provide `nanobot webui`. When run without a usable model, that launcher offers the same Quick Start flow before starting the browser.
|
||||||
|
|
||||||
| Path | What it is |
|
## 3. Check the Setup
|
||||||
|------|------------|
|
|
||||||
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API |
|
|
||||||
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
|
|
||||||
|
|
||||||
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values. Use `nanobot onboard --refresh` to do the same refresh without an interactive prompt.
|
```bash
|
||||||
|
nanobot status
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Configure a Provider
|
You want:
|
||||||
|
|
||||||
Skip this section if you already configured provider and model settings in the wizard.
|
- a check mark for **Config** and **Workspace**;
|
||||||
|
- the model or preset you selected;
|
||||||
|
- a configured state for the provider used by that model.
|
||||||
|
|
||||||
Open `~/.nanobot/config.json`. Add or merge these blocks into the file created by `nanobot onboard`; do not replace the whole file unless you want to reset the config.
|
Most other providers can say `not set`. This command validates local setup but does not call the model.
|
||||||
|
|
||||||
**API key:**
|
## 4. Get the First Reply
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
Quick Start has already prepared the local WebSocket channel. Leave the gateway terminal open and visit `http://127.0.0.1:8765`; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it. On current source versions, you can run `nanobot webui` instead to perform the local WebUI checks, start the gateway, and open the browser automatically.
|
||||||
|
|
||||||
|
Send:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Hello!
|
||||||
|
```
|
||||||
|
|
||||||
|
Any normal assistant answer is success. It proves that nanobot can load the config, reach the selected model, use the workspace, and serve the browser UI.
|
||||||
|
|
||||||
|
Leave the terminal open while using the WebUI. If you prefer a managed background process, stop the foreground process with `Ctrl+C`, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway --background
|
||||||
|
nanobot gateway status
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `nanobot gateway logs`, `restart`, and `stop` to manage that background gateway.
|
||||||
|
|
||||||
|
## Terminal-Only Check
|
||||||
|
|
||||||
|
If you do not want the browser or need to isolate a WebUI problem, send one message directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then start an interactive terminal chat with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot agent
|
||||||
|
```
|
||||||
|
|
||||||
|
In interactive mode, `Enter` sends and `Alt+Enter` inserts a newline. Exit with `exit`, `/exit`, `:q`, or `Ctrl+D`.
|
||||||
|
|
||||||
|
## Choose One Next Step
|
||||||
|
|
||||||
|
After the first reply works, add one capability and test again:
|
||||||
|
|
||||||
|
| Goal | Recommended path |
|
||||||
|
|---|---|
|
||||||
|
| Learn sessions, workspaces, tools, and access modes | [WebUI guide](./webui.md) |
|
||||||
|
| Connect a chat platform | Open **Settings → Channels**, then use [Chat Apps](./chat-apps.md) for platform prerequisites |
|
||||||
|
| Change or add a model | Open **Settings → Models**; use the [Provider Cookbook](./provider-cookbook.md) for a recipe |
|
||||||
|
| Add web search, voice, or image generation | Use the matching WebUI Settings page, then consult [Configuration](./configuration.md) for advanced fields |
|
||||||
|
| Add an App or MCP integration | Open **Apps** or follow [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||||
|
| Schedule agent work | Read [Automations](./automations.md) |
|
||||||
|
| Run continuously or remotely | Read [Deployment](./deployment.md) |
|
||||||
|
| Integrate from code | Use the [Python SDK](./python-sdk.md) or [OpenAI-Compatible API](./openai-api.md) |
|
||||||
|
|
||||||
|
## Other Install Methods
|
||||||
|
|
||||||
|
Use one method, then continue at [Complete Quick Start](#2-complete-quick-start).
|
||||||
|
|
||||||
|
**uv**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
**pip in a virtual environment**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install nanobot-ai
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
If pip reports `externally-managed-environment`, use the recommended installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment. Do not force a system-wide install.
|
||||||
|
|
||||||
|
**Current source**
|
||||||
|
|
||||||
|
`bun` or `npm` must be available. Activate a virtual environment first, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/HKUDS/nanobot.git
|
||||||
|
cd nanobot
|
||||||
|
python -m pip install .
|
||||||
|
nanobot onboard --wizard
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install.
|
||||||
|
|
||||||
|
The source path follows current `main` and can be newer than the published package. A non-editable install triggers the build hook that bundles the current WebUI. For editable Python or frontend development, follow [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../webui/README.md`](../webui/README.md).
|
||||||
|
|
||||||
|
If the package is installed but the shell cannot find `nanobot`, use the runner that owns the installation. The recommended installer prints the exact command to reuse. Common forms are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool run --from nanobot-ai nanobot --version
|
||||||
|
pipx run --spec nanobot-ai nanobot --version
|
||||||
|
~/.nanobot/venv/bin/python -m nanobot --version
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `onboard --wizard`, `gateway`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
|
||||||
|
|
||||||
|
## Manual Configuration Fallback
|
||||||
|
|
||||||
|
Use this only when the wizard is unavailable or you intentionally manage JSON. First run `nanobot onboard`, then merge a provider and a named model preset into `~/.nanobot/config.json`.
|
||||||
|
|
||||||
|
A generic OpenAI-compatible setup has this shape:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
"custom": {
|
"custom": {
|
||||||
"apiKey": "your-api-key",
|
"apiKey": "${PROVIDER_API_KEY}",
|
||||||
"apiBase": "https://api.example.com/v1"
|
"apiBase": "https://api.example.com/v1"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Model preset:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
"primary": {
|
"primary": {
|
||||||
"label": "Primary",
|
|
||||||
"provider": "custom",
|
"provider": "custom",
|
||||||
"model": "model-id-from-your-provider",
|
"model": "model-id-from-your-provider"
|
||||||
"maxTokens": 8192,
|
|
||||||
"contextWindowTokens": 65536,
|
|
||||||
"temperature": 0.1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -158,192 +202,48 @@ Open `~/.nanobot/config.json`. Add or merge these blocks into the file created b
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The provider and model inside a preset must match. The snippet above is only an example. For another provider, replace these values together:
|
Replace the provider, endpoint, and model together. Do not pair a credential from one service with a model ID from another. See [Provider Cookbook](./provider-cookbook.md) for hosted, OAuth, company, and local examples, and [Configuration](./configuration.md) for exact fields.
|
||||||
|
|
||||||
| Replace | Where |
|
|
||||||
|---|---|
|
|
||||||
| Provider config key, such as `custom` | `providers.<provider>` |
|
|
||||||
| API key or environment variable | `providers.<provider>.apiKey` |
|
|
||||||
| Preset provider name | `modelPresets.primary.provider` |
|
|
||||||
| Model ID | `modelPresets.primary.model` |
|
|
||||||
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
|
|
||||||
|
|
||||||
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and fallback chains. For provider-specific examples across direct, gateway, OAuth, cloud, and local setups, see [`providers.md`](./providers.md).
|
|
||||||
|
|
||||||
**What about `apiBase` / base URL?**
|
|
||||||
|
|
||||||
`apiBase` is the HTTP base URL of the provider endpoint, not the model name. Most hosted providers in nanobot already know their default endpoint, so you usually only set `apiKey` and a model preset. Set `apiBase` when you are using:
|
|
||||||
|
|
||||||
- `custom` for a third-party or self-hosted OpenAI-compatible API;
|
|
||||||
- a local OpenAI-compatible server such as Ollama, vLLM, or LM Studio;
|
|
||||||
- a provider-specific alternate endpoint, regional endpoint, proxy, or subscription endpoint.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"custom": {
|
|
||||||
"apiKey": "${CUSTOM_API_KEY}",
|
|
||||||
"apiBase": "https://api.example.com/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"ollama": {
|
|
||||||
"apiBase": "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the provider's docs say the endpoint is `/v1`, include `/v1` in `apiBase`. The model ID still belongs in the active `modelPresets` entry.
|
|
||||||
|
|
||||||
If you prefer not to store secrets in `config.json`, reference an environment variable and set it before starting nanobot:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"custom": {
|
|
||||||
"apiKey": "${PROVIDER_API_KEY}",
|
|
||||||
"apiBase": "https://api.example.com/v1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Check the Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot status
|
|
||||||
```
|
|
||||||
|
|
||||||
This should show the config path, workspace path, active model or preset, and provider summary. It does not send a message to the model, so use it as a quick config check before the first real request.
|
|
||||||
|
|
||||||
Read it like this:
|
|
||||||
|
|
||||||
| Status line | What you want |
|
|
||||||
|---|---|
|
|
||||||
| `Config` | A check mark. |
|
|
||||||
| `Workspace` | A check mark. |
|
|
||||||
| `Model` | The model or preset you expect. |
|
|
||||||
| Provider list | Most providers can say `not set`; the provider used by the active preset should show a check mark, OAuth status, or local URL. |
|
|
||||||
|
|
||||||
## 5. Open the WebUI
|
|
||||||
|
|
||||||
Start the browser workbench:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot webui
|
|
||||||
```
|
|
||||||
|
|
||||||
`nanobot webui` prepares the local WebSocket channel and WebUI bootstrap secret if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal.
|
|
||||||
|
|
||||||
## 6. Test One CLI Message
|
|
||||||
|
|
||||||
Use this path if you skipped Quick Start, declined the WebSocket channel, or want a terminal-only check.
|
|
||||||
|
|
||||||
Run a one-shot CLI message:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot agent -m "Hello!"
|
|
||||||
```
|
|
||||||
|
|
||||||
A successful first run proves that:
|
|
||||||
|
|
||||||
- the `nanobot` command is installed;
|
|
||||||
- `~/.nanobot/config.json` can be loaded;
|
|
||||||
- the selected provider and model can answer;
|
|
||||||
- the default workspace can be created and used.
|
|
||||||
|
|
||||||
The reply text itself will vary. Any normal assistant answer means the install, config, provider, model, and workspace path are all usable.
|
|
||||||
|
|
||||||
If that works, start an interactive CLI chat:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot agent
|
|
||||||
```
|
|
||||||
|
|
||||||
After the interactive session can answer normally, nanobot can help with its own next setup step. Ask it to read the relevant docs, inspect your current `~/.nanobot/config.json`, and make one concrete change such as enabling WebUI, adding a provider preset, or configuring one chat channel. When nanobot says the config is updated, run `/restart` in the chat or restart the nanobot process manually so long-running processes reload `config.json`.
|
|
||||||
|
|
||||||
Example prompt:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Read docs/quick-start.md, docs/providers.md, and docs/configuration.md in this checkout.
|
|
||||||
Then update ~/.nanobot/config.json to add a model preset named "primary" for my provider.
|
|
||||||
Tell me exactly what changed and whether I need to run /restart.
|
|
||||||
```
|
|
||||||
|
|
||||||
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
|
|
||||||
|
|
||||||
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
|
||||||
|
|
||||||
## 7. Choose Your Next Step
|
|
||||||
|
|
||||||
| Want to... | Go to |
|
|
||||||
|---|---|
|
|
||||||
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
|
|
||||||
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
|
|
||||||
| Understand provider/model matching | [`providers.md`](./providers.md) |
|
|
||||||
| Open the bundled browser UI | [`webui.md`](./webui.md) |
|
|
||||||
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [`chat-apps.md`](./chat-apps.md) |
|
|
||||||
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
|
|
||||||
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
|
|
||||||
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
|
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
**pip:**
|
Upgrade with the same method you used to install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -U nanobot-ai
|
# Recommended installer
|
||||||
nanobot --version
|
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
||||||
```
|
|
||||||
|
|
||||||
If pip reports `externally-managed-environment`, upgrade with the same isolated method you used to install nanobot, such as `uv tool upgrade nanobot-ai`, `pipx upgrade nanobot-ai`, or the managed venv created by the one-command installer.
|
# Or one of these
|
||||||
|
|
||||||
**uv:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv tool upgrade nanobot-ai
|
uv tool upgrade nanobot-ai
|
||||||
nanobot --version
|
|
||||||
```
|
|
||||||
|
|
||||||
**pipx:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx upgrade nanobot-ai
|
pipx upgrade nanobot-ai
|
||||||
nanobot --version
|
python -m pip install -U nanobot-ai
|
||||||
```
|
```
|
||||||
|
|
||||||
**Source checkout:**
|
For a source checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git pull
|
git pull
|
||||||
python -m pip install -e .
|
python -m pip install .
|
||||||
nanobot --version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings.
|
||||||
|
|
||||||
|
## If the First Reply Fails
|
||||||
|
|
||||||
|
Do not change several settings at once. Start with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot plugins enable whatsapp
|
nanobot --version
|
||||||
|
nanobot status
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
## First-Run Troubleshooting
|
| Symptom | First check |
|
||||||
|
|---|---|
|
||||||
|
| `nanobot: command not found` | Reuse the installer command or method-specific runner described under [Other Install Methods](#other-install-methods) |
|
||||||
|
| JSON parse error | Check commas and braces; remember that docs examples are usually snippets |
|
||||||
|
| `401` or invalid API key | Verify the selected provider owns that key and remove accidental spaces |
|
||||||
|
| Model not found | Use a model ID available from the provider selected in the active preset |
|
||||||
|
| CLI works but WebUI does not open | Use port `8765`, not gateway health port `18790` |
|
||||||
|
| WebUI works but a chat app does not | Check **Settings → Channels**, then run `nanobot channels status` |
|
||||||
|
|
||||||
| Symptom | What to check |
|
Continue with the ordered [Troubleshooting guide](./troubleshooting.md) if the cause is still unclear.
|
||||||
|---------|---------------|
|
|
||||||
| `nanobot: command not found` | Use `python -m nanobot ...`, or add your Python scripts directory to `PATH`. |
|
|
||||||
| `ModuleNotFoundError: nanobot` | Confirm you installed into the same Python environment that is running the command. |
|
|
||||||
| JSON parse errors | Check commas and braces in `~/.nanobot/config.json`; examples above are partial snippets to merge. |
|
|
||||||
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
|
|
||||||
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
|
|
||||||
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
|
|
||||||
| WebUI does not open | Run `nanobot webui`; the browser UI uses port `8765`, not the gateway health port `18790`. |
|
|
||||||
|
|
||||||
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).
|
|
||||||
|
|||||||
@@ -1,76 +1,62 @@
|
|||||||
# Start Without Technical Background
|
# Start Without Technical Background
|
||||||
|
|
||||||
This page is for you if you have never used a terminal, edited a JSON file, or configured an AI model before.
|
This walkthrough is for people who have not used a terminal, API key, or JSON config file before. The goal is only to get one reply in a browser. You do not need to understand nanobot's architecture or edit its config by hand.
|
||||||
|
|
||||||
The goal is small: get one local nanobot reply in your browser. Do not connect Telegram, Discord, Docker, local models, or deployment yet. Those are easier after the first reply works.
|
## What You Will Need
|
||||||
|
|
||||||
## What You Are Setting Up
|
- A Windows, macOS, or Linux computer.
|
||||||
|
- Python 3.11 or newer.
|
||||||
|
- An account or endpoint that can run an AI model.
|
||||||
|
- The API key, login, endpoint, and model name required by that service. A local model such as Ollama may not require an API key.
|
||||||
|
|
||||||
You only need these words for Quick Start:
|
An API key is password-like. Do not post it in an issue, screenshot, chat, or public config file.
|
||||||
|
|
||||||
| Word | Plain meaning |
|
## A Few Useful Words
|
||||||
|
|
||||||
|
| Word | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Terminal | A text window where you paste commands and press Enter. |
|
| Terminal | A text window where you paste a command and press Enter |
|
||||||
| Command | One line of text you run in the terminal. |
|
| Command | One instruction typed into the terminal |
|
||||||
| API key | A password-like token from an AI provider. Do not share it publicly. |
|
| Provider | The service or local server that runs the AI model |
|
||||||
| Config file | The settings file nanobot reads when it starts. |
|
| Model ID | The exact model name expected by that provider |
|
||||||
| Wizard | An interactive terminal menu that edits the config file for you. |
|
| API key | A secret credential that lets software call the provider |
|
||||||
| Browser UI | The local web page where you chat with nanobot. |
|
| Wizard | A question-and-answer setup menu |
|
||||||
|
| WebUI | The local browser page where you use nanobot |
|
||||||
|
|
||||||
## 1. Open a Terminal
|
## 1. Install Python
|
||||||
|
|
||||||
You will paste commands into a terminal. Copy only the command text inside each code block; do not copy the ``` marks.
|
Download Python from [python.org](https://www.python.org/downloads/) if you do not already have version 3.11 or newer. On Windows, enable **Add python.exe to PATH** if the installer shows that option.
|
||||||
|
|
||||||
| System | How to open it |
|
Open a terminal:
|
||||||
|
|
||||||
|
| System | How |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Windows | Press `Win`, type `PowerShell`, then open **Windows PowerShell**. |
|
| Windows | Press `Win`, type `PowerShell`, and open Windows PowerShell |
|
||||||
| macOS | Press `Command` + `Space`, type `Terminal`, then press `Enter`. |
|
| macOS | Press `Command+Space`, type `Terminal`, and press Enter |
|
||||||
| Linux | Open your app launcher, search for `Terminal`, then open it. |
|
| Linux | Open your application menu and search for Terminal |
|
||||||
|
|
||||||
When the terminal opens, click inside it, paste the command, and press `Enter`. If a command prints text and returns to a prompt, that is usually normal.
|
Check Python:
|
||||||
|
|
||||||
## 2. Install Python
|
|
||||||
|
|
||||||
Install Python 3.11 or newer from [python.org](https://www.python.org/downloads/).
|
|
||||||
|
|
||||||
On Windows, enable **Add python.exe to PATH** during installation if the installer shows that option.
|
|
||||||
|
|
||||||
In that terminal, check Python:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python --version
|
python --version
|
||||||
```
|
```
|
||||||
|
|
||||||
If Windows says `python` is not found, close and reopen PowerShell. If it still does not work, try:
|
The result should start with `Python 3.11` or a newer number. If the command is not found, close and reopen the terminal. You can also try `python3 --version` on macOS/Linux or `py --version` on Windows.
|
||||||
|
|
||||||
```bash
|
## 2. Prepare Your Model Details
|
||||||
py --version
|
|
||||||
```
|
|
||||||
|
|
||||||
If `py` works but `python` does not, replace `python` with `py` in the commands below.
|
nanobot does not create an AI provider account for you. Before setup, have these details nearby:
|
||||||
|
|
||||||
If macOS or Linux says `python` is not found, try:
|
1. The provider or company endpoint name.
|
||||||
|
2. Its API key, if it requires one.
|
||||||
|
3. Its base URL, if its documentation gives you one.
|
||||||
|
4. A model ID your account can use.
|
||||||
|
|
||||||
```bash
|
The provider, credential, endpoint, and model must belong together. For example, an API key from one provider usually cannot call a model name copied from a different provider.
|
||||||
python3 --version
|
|
||||||
```
|
|
||||||
|
|
||||||
If `python3` works but `python` does not, replace `python` with `python3` in the manual commands below. The one-command installer already checks both `python3` and `python`.
|
## 3. Install nanobot
|
||||||
|
|
||||||
## 3. Get a Provider API Key
|
Copy the command for your system, paste it into the terminal, and press Enter. Copy only the text inside the code block.
|
||||||
|
|
||||||
nanobot does not create AI accounts or API keys for you. Use an AI provider account, company endpoint, subscription endpoint, or local model server that you already control. If the provider has an OpenAI-compatible base URL in its docs, keep that nearby too.
|
|
||||||
|
|
||||||
For the setup path:
|
|
||||||
|
|
||||||
1. Open your provider's API key page.
|
|
||||||
2. Create or copy an API key.
|
|
||||||
3. Keep the key private.
|
|
||||||
4. Keep the provider's base URL nearby if the provider docs show one.
|
|
||||||
|
|
||||||
## 4. Install nanobot
|
|
||||||
|
|
||||||
The easiest path is the one-command installer. It installs or upgrades nanobot, then starts the setup wizard. On macOS and Linux it avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`.
|
|
||||||
|
|
||||||
**macOS / Linux**
|
**macOS / Linux**
|
||||||
|
|
||||||
@@ -84,75 +70,13 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
|
|||||||
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`:
|
The installer downloads the stable nanobot package into an isolated Python environment and opens the setup wizard. It can take a few minutes on the first run. When it finishes, it prints the exact command it used to run nanobot. Keep that command: if `nanobot` is not found later, reuse the whole printed command instead of switching to a different Python command.
|
||||||
|
|
||||||
```bash
|
If your organization blocks downloaded install scripts, use the [alternative install methods](./quick-start.md#other-install-methods) or ask your administrator to review the scripts first.
|
||||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
## 4. Follow Quick Start
|
||||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the development installer only when a maintainer asks you to test the current `main` branch:
|
The wizard shows a menu similar to:
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
|
||||||
```
|
|
||||||
|
|
||||||
If the command says `curl` or `irm` is not found, or it cannot download from GitHub, use one of the manual install commands below.
|
|
||||||
|
|
||||||
If `uv` is installed, use:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv tool install nanobot-ai
|
|
||||||
```
|
|
||||||
|
|
||||||
If you prefer pip, use it only inside an environment you control:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pip install nanobot-ai
|
|
||||||
```
|
|
||||||
|
|
||||||
If pip reports `externally-managed-environment` on macOS or Linux, go back to the one-command installer, use `uv tool install nanobot-ai`, use `pipx install nanobot-ai`, or create a virtual environment first.
|
|
||||||
|
|
||||||
Then check that nanobot is installed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot --version
|
|
||||||
```
|
|
||||||
|
|
||||||
If the terminal cannot find `nanobot`, use the module form:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m nanobot --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `python3 -m nanobot --version` or `py -m nanobot --version` if that is the Python command that worked in step 2.
|
|
||||||
|
|
||||||
## 5. Run the Setup Wizard
|
|
||||||
|
|
||||||
The one-command installer starts this for you after installation. If you installed manually, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot onboard --wizard
|
|
||||||
```
|
|
||||||
|
|
||||||
If `nanobot` is not found, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m nanobot onboard --wizard
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `python3 -m nanobot onboard --wizard` or `py -m nanobot onboard --wizard` if that is the Python command that worked in step 2.
|
|
||||||
|
|
||||||
The wizard is a terminal menu. It is not a graphical app, but it lets you choose options instead of hand-editing every JSON field.
|
|
||||||
|
|
||||||
You will see a menu like this:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
> What would you like to do?
|
> What would you like to do?
|
||||||
@@ -161,259 +85,97 @@ You will see a menu like this:
|
|||||||
[X] Exit
|
[X] Exit
|
||||||
```
|
```
|
||||||
|
|
||||||
Move through the wizard like this:
|
Choose **Quick Start**. Use the arrow keys to highlight an option and press `Enter`.
|
||||||
|
|
||||||
| When you see | Do this |
|
The wizard asks for only the information needed for the first reply:
|
||||||
|---|---|
|
|
||||||
| A menu | Use the arrow keys to highlight an option, then press `Enter`. |
|
|
||||||
| The provider menu | Choose the company or service you want to use. |
|
|
||||||
| An endpoint menu | Choose the standard API or subscription plan endpoint that matches your key. |
|
|
||||||
| An API key field | Paste the key, then press `Enter`. |
|
|
||||||
| A provider base URL field | Paste the provider base URL from its docs, then press `Enter`. |
|
|
||||||
| The Model ID field | Paste a model name from your provider, then press `Enter`. |
|
|
||||||
| A back option in Advanced Settings | Choose it to return to the previous menu. |
|
|
||||||
|
|
||||||
For the first setup, choose `[Q] Quick Start`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a chat app, a tool setup, or provider-specific fields.
|
1. Choose your provider.
|
||||||
|
2. Choose an endpoint option if the provider offers several plans.
|
||||||
|
3. Paste the API key if asked.
|
||||||
|
4. Enter the base URL if asked.
|
||||||
|
5. Enter a model ID.
|
||||||
|
6. Confirm the local WebUI setup.
|
||||||
|
7. Choose a WebUI password.
|
||||||
|
8. Review the summary and save.
|
||||||
|
|
||||||
1. Choose `[Q] Quick Start`.
|
When you paste a password or API key, the terminal may hide the characters. That is normal.
|
||||||
2. Choose the provider you want to use.
|
|
||||||
3. Choose the endpoint if the wizard asks, such as Standard API, Coding Plan, Token Plan, or Step Plan.
|
|
||||||
4. Paste your API key if the wizard asks for one.
|
|
||||||
5. Paste the provider base URL if the wizard asks for one.
|
|
||||||
6. Paste a model ID that provider can run.
|
|
||||||
7. Confirm that Quick Start should configure the local WebUI.
|
|
||||||
8. Set the WebUI password when prompted.
|
|
||||||
9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
|
|
||||||
|
|
||||||
The recommended path configures the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
|
If the installer finishes without opening the wizard and `nanobot` is available, run:
|
||||||
|
|
||||||
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
|
|
||||||
|
|
||||||
The wizard creates or updates:
|
|
||||||
|
|
||||||
| Path | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `~/.nanobot/config.json` | Settings file. |
|
|
||||||
| `~/.nanobot/workspace/` | Working folder for memory, sessions, and generated files. |
|
|
||||||
|
|
||||||
If Quick Start finished successfully, skip to [Open the WebUI](#7-open-the-webui). The next two sections are only for manual setup.
|
|
||||||
|
|
||||||
## Manual Setup: How to Merge JSON Snippets
|
|
||||||
|
|
||||||
Most docs examples are snippets, not whole files. Your `config.json` has one outer `{ ... }`. Add new top-level sections such as `providers`, `modelPresets`, `agents`, or `channels` inside that same outer object.
|
|
||||||
|
|
||||||
Do not paste two separate JSON objects into one file:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{
|
|
||||||
"providers": { "...": "..." }
|
|
||||||
}
|
|
||||||
{
|
|
||||||
"channels": { "...": "..." }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Merge them into one object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"custom": {
|
|
||||||
"apiKey": "your-api-key",
|
|
||||||
"apiBase": "https://api.example.com/v1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"channels": {
|
|
||||||
"websocket": {
|
|
||||||
"tokenIssueSecret": "your-webui-password",
|
|
||||||
"websocketRequiresToken": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard --wizard` whenever possible.
|
|
||||||
|
|
||||||
## 6. Manual Setup: Config Fallback
|
|
||||||
|
|
||||||
Use this only if the wizard is unavailable or you prefer opening the file yourself.
|
|
||||||
|
|
||||||
Run `nanobot onboard` first if `~/.nanobot/config.json` does not exist yet.
|
|
||||||
|
|
||||||
Use one of these commands:
|
|
||||||
|
|
||||||
**Windows PowerShell**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
notepad "$env:USERPROFILE\.nanobot\config.json"
|
|
||||||
```
|
|
||||||
|
|
||||||
**macOS**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
open -e ~/.nanobot/config.json
|
nanobot onboard --wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
**Linux**
|
If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `onboard --wizard`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
|
||||||
|
|
||||||
|
## 5. Open the Browser
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xdg-open ~/.nanobot/config.json
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
If this is a brand-new install and you have not configured anything else yet, replace the file with this minimal config:
|
Leave the terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password from the wizard if the browser asks for it. Current source versions also provide `nanobot webui`, which starts the gateway and opens the browser automatically.
|
||||||
|
|
||||||
```json
|
Send this message:
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"custom": {
|
|
||||||
"apiKey": "your-api-key",
|
|
||||||
"apiBase": "https://api.example.com/v1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"modelPresets": {
|
|
||||||
"primary": {
|
|
||||||
"label": "Primary",
|
|
||||||
"provider": "custom",
|
|
||||||
"model": "model-id-from-your-provider",
|
|
||||||
"maxTokens": 4096,
|
|
||||||
"contextWindowTokens": 65536,
|
|
||||||
"temperature": 0.1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"modelPreset": "primary"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"channels": {
|
|
||||||
"websocket": {
|
|
||||||
"tokenIssueSecret": "your-webui-password",
|
|
||||||
"websocketRequiresToken": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `your-api-key`, `https://api.example.com/v1`, `model-id-from-your-provider`, and `your-webui-password` with your own values.
|
|
||||||
|
|
||||||
For copyable provider-specific examples, use [`provider-cookbook.md`](./provider-cookbook.md).
|
|
||||||
|
|
||||||
Save the file.
|
|
||||||
|
|
||||||
## 7. Open the WebUI
|
|
||||||
|
|
||||||
First check that nanobot can read the saved setup:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot status
|
|
||||||
```
|
|
||||||
|
|
||||||
This should show the config file path, workspace path, and the active model or preset. If `nanobot` is not found, use `python -m nanobot status`, `python3 -m nanobot status`, or `py -m nanobot status`, matching the Python command that worked in step 2.
|
|
||||||
|
|
||||||
It is normal for most providers to say `not set`. Only the provider you selected for the active preset needs to look configured.
|
|
||||||
|
|
||||||
Start the local browser UI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot webui
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts nanobot and opens `http://127.0.0.1:8765` in your browser. Leave the terminal open while you use the WebUI. Enter the WebUI password you set in the wizard if the browser asks for one.
|
|
||||||
|
|
||||||
Send this first message in the browser:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Hello!
|
Hello!
|
||||||
```
|
```
|
||||||
|
|
||||||
If that works, nanobot is installed and can call the model. You should see a normal assistant reply in the browser. The exact words will differ, but it should look like this shape:
|
A normal assistant reply means setup is complete. The exact reply does not matter.
|
||||||
|
|
||||||
```text
|
The first-run address is local to your computer. It is not automatically available to other computers on your network.
|
||||||
Hello! How can I help you today?
|
|
||||||
```
|
|
||||||
|
|
||||||
If `nanobot` is not found, run:
|
## 6. Add One Thing at a Time
|
||||||
|
|
||||||
|
Do not configure every feature immediately. Choose one next goal:
|
||||||
|
|
||||||
|
| Goal | What to do |
|
||||||
|
|---|---|
|
||||||
|
| Change the AI model | Open **Settings → Models** |
|
||||||
|
| Add a provider credential | Open **Settings → Models**, then find the provider |
|
||||||
|
| Connect Telegram, Discord, Slack, Feishu, WeChat, or another chat app | Open **Settings → Channels**, choose the platform, and follow its connection steps |
|
||||||
|
| Add a tool integration | Open **Apps** and choose an App or MCP integration |
|
||||||
|
| Schedule a reminder or recurring task | Ask nanobot in the target chat, then manage it in **Automations** |
|
||||||
|
| Work with project files | Start a new chat, choose the project workspace, and review the access setting before sending the task |
|
||||||
|
|
||||||
|
Repository docs show the current development version. If your stable package does not yet show **Settings → Channels**, use the [Chat Apps guide](./chat-apps.md) or update to a release that includes it.
|
||||||
|
|
||||||
|
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot gateway` again.
|
||||||
|
|
||||||
|
For a chat platform's account, bot, token, or permission prerequisites, use the [Chat Apps guide](./chat-apps.md). For local models and provider-specific recipes, use the [Provider Cookbook](./provider-cookbook.md).
|
||||||
|
|
||||||
|
## If Something Fails
|
||||||
|
|
||||||
|
Run these commands one at a time:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m nanobot webui
|
nanobot --version
|
||||||
|
nanobot status
|
||||||
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `python3 -m nanobot webui` or `py -m nanobot webui` if that is the Python command that worked in step 2.
|
| What you see | What it usually means |
|
||||||
|
|
||||||
Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app.
|
|
||||||
|
|
||||||
## 8. If Something Fails
|
|
||||||
|
|
||||||
Do not change many things at once. Check the exact error:
|
|
||||||
|
|
||||||
| Error or symptom | What it usually means |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| `JSON parse error` | The config file has a missing comma, extra comma, or mismatched brace. Copy the example again. |
|
| `nanobot: command not found` | Reuse the exact nanobot command printed by the installer; it points to the isolated environment that contains the package |
|
||||||
| `401`, `unauthorized`, or `invalid API key` | The API key is wrong, expired, has extra spaces, or was pasted under the wrong provider. |
|
| `401`, unauthorized, or invalid API key | The key is wrong, expired, or belongs to a different provider |
|
||||||
| `model not found` | Your account cannot use the default model. Return to `nanobot onboard --wizard`, choose `Advanced Settings`, then edit `Model Presets`. |
|
| Model not found | The model ID is misspelled or unavailable to your provider account |
|
||||||
| `nanobot: command not found` | The install worked in Python, but your shell cannot find the script. Use `python -m nanobot ...`, `python3 -m nanobot ...`, or `py -m nanobot ...`, matching the Python command that worked earlier. |
|
| Browser does not open | Open `http://127.0.0.1:8765` yourself and keep the terminal running |
|
||||||
| No response after editing config | Restart the command. Long-running processes read config when they start. |
|
| Browser opens but messages fail | Test `nanobot agent -m "Hello!"` to separate a model problem from a WebUI problem |
|
||||||
|
| A change was saved but nothing changed | Restart nanobot so the running process reloads the config |
|
||||||
|
|
||||||
For a fuller diagnosis path, see [`troubleshooting.md`](./troubleshooting.md).
|
If you ask for help, include your operating system, `nanobot --version`, `nanobot status`, the exact command, and the exact error. Remove every API key, bot token, password, OAuth token, and private account ID first.
|
||||||
|
|
||||||
## What Not to Configure Yet
|
Continue with the full [Troubleshooting guide](./troubleshooting.md) for an ordered diagnosis.
|
||||||
|
|
||||||
Skip these until the first local message works:
|
## Open nanobot Later
|
||||||
|
|
||||||
- `apiBase`: hosted built-in providers often already have default endpoints. You only need `apiBase` for local models, proxies, custom OpenAI-compatible providers, or special regional/subscription endpoints.
|
|
||||||
- chat apps: first prove the local browser UI can answer.
|
|
||||||
- fallback models: useful later, but not needed for the first reply.
|
|
||||||
- Langfuse: useful for observability, but not needed for first setup.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot webui` open whenever you use the WebUI. Chat apps use the same gateway service underneath.
|
|
||||||
|
|
||||||
### Open the Browser UI Again
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot webui
|
|
||||||
```
|
|
||||||
|
|
||||||
Leave that terminal open; the browser should open automatically.
|
|
||||||
|
|
||||||
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
|
|
||||||
|
|
||||||
If `nanobot` is not found, run `python -m nanobot webui`, `python3 -m nanobot webui`, or `py -m nanobot webui`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
|
|
||||||
|
|
||||||
### Connect a Chat App
|
|
||||||
|
|
||||||
1. Read the section for one app in [`chat-apps.md`](./chat-apps.md).
|
|
||||||
2. Add only that app's config snippet. Merge it into the existing file instead of replacing the whole file.
|
|
||||||
3. Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot channels status
|
|
||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Leave the gateway terminal open, then send a message from the allowed account.
|
Leave that terminal open and visit `http://127.0.0.1:8765`. To stop nanobot, return to the terminal and press `Ctrl+C`. Use `nanobot gateway --background` only after the normal foreground start works; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||||
|
|
||||||
Start with a private chat or a test server. Do not set `allowFrom` to `["*"]` unless you intentionally want anyone who can reach that channel to talk to the bot.
|
|
||||||
|
|
||||||
### Change Models or Add Backups
|
|
||||||
|
|
||||||
Use [`providers.md`](./providers.md) when a provider/model pair fails, and [`provider-cookbook.md`](./provider-cookbook.md) when you want copyable snippets. Keep model choices in `modelPresets`, then select the active one with `agents.defaults.modelPreset`.
|
|
||||||
|
|
||||||
### Ask for Help
|
|
||||||
|
|
||||||
When you ask for help, include:
|
|
||||||
|
|
||||||
- your operating system;
|
|
||||||
- the command you ran;
|
|
||||||
- `nanobot --version`;
|
|
||||||
- `nanobot status`;
|
|
||||||
- whether the browser UI can answer `Hello!`;
|
|
||||||
- the exact error text;
|
|
||||||
- a config snippet with API keys and tokens removed.
|
|
||||||
|
|
||||||
Never paste real API keys, bot tokens, OAuth tokens, or private chat IDs into a public issue or chat.
|
|
||||||
|
|
||||||
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
|
|
||||||
|
|||||||
+30
-3
@@ -53,6 +53,18 @@ WebUI beyond localhost or want a browser password:
|
|||||||
The WebUI is served by the WebSocket channel on port `8765` by default. The
|
The WebUI is served by the WebSocket channel on port `8765` by default. The
|
||||||
gateway health endpoint, `18790` by default, is not the browser UI.
|
gateway health endpoint, `18790` by default, is not the browser UI.
|
||||||
|
|
||||||
|
## First 10 Minutes
|
||||||
|
|
||||||
|
Use the WebUI as the primary setup surface after Quick Start:
|
||||||
|
|
||||||
|
1. Send `Hello!` in a new chat to prove the selected model works.
|
||||||
|
2. Open **Settings → Models** and confirm the active model preset.
|
||||||
|
3. Start a separate chat before project work, then choose the intended workspace and access mode.
|
||||||
|
4. Add only one capability next: a chat channel in **Settings → Channels**, a web/voice/image provider in **Settings**, or an App/MCP integration in **Apps**.
|
||||||
|
5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request.
|
||||||
|
|
||||||
|
This path avoids hand-editing `config.json` for normal setup. Use the reference docs when you need an option the WebUI does not expose or when you manage config as code.
|
||||||
|
|
||||||
## What It Is For
|
## What It Is For
|
||||||
|
|
||||||
| Area | Use it for |
|
| Area | Use it for |
|
||||||
@@ -62,6 +74,7 @@ gateway health endpoint, `18790` by default, is not the browser UI.
|
|||||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
||||||
|
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
||||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
||||||
@@ -113,6 +126,20 @@ For image generation, configure an image provider first and then use the WebUI
|
|||||||
image mode from the composer. See [`image-generation.md`](./image-generation.md)
|
image mode from the composer. See [`image-generation.md`](./image-generation.md)
|
||||||
for provider setup and output behavior.
|
for provider setup and output behavior.
|
||||||
|
|
||||||
|
## Channels
|
||||||
|
|
||||||
|
Open **Settings → Channels** to connect chat apps without assembling JSON by hand. Search for a platform, open its setup panel, and follow the fields or QR flow shown for that channel. The guided setup can:
|
||||||
|
|
||||||
|
- install missing optional channel support when the WebUI is running locally;
|
||||||
|
- collect platform credentials while preserving previously saved values;
|
||||||
|
- handle supported QR-based login flows;
|
||||||
|
- validate the connection and show actionable setup errors;
|
||||||
|
- tell you when the gateway needs to restart.
|
||||||
|
|
||||||
|
The platform itself may still require you to create a bot, enable event permissions, copy a token, or configure a webhook. Use [`chat-apps.md`](./chat-apps.md) for those platform-side prerequisites and for manual JSON/reference options.
|
||||||
|
|
||||||
|
Test a new channel with a private DM. When a supported channel sends a pairing code, the WebUI surfaces the pending request so you can approve the sender. Keep access narrow; do not use a wildcard allowlist unless public access is intentional.
|
||||||
|
|
||||||
## Apps
|
## Apps
|
||||||
|
|
||||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||||
@@ -194,9 +221,9 @@ with the content that should be delivered.
|
|||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Settings is the control surface for the browser session and gateway-backed
|
Settings is the control surface for the browser session and gateway-backed
|
||||||
runtime configuration. Use it to review or adjust model presets, provider
|
runtime configuration. Use it to review or adjust model presets, providers,
|
||||||
visibility, image generation, voice transcription, web tools, Apps, Automations,
|
image generation, voice transcription, web tools, chat channels, Apps,
|
||||||
Skills, runtime identity, and advanced safety controls.
|
Automations, Skills, runtime identity, and advanced safety controls.
|
||||||
|
|
||||||
Some settings take effect immediately. Runtime settings that affect the gateway
|
Some settings take effect immediately. Runtime settings that affect the gateway
|
||||||
or agent process may require a restart; the WebUI shows that requirement next to
|
or agent process may require a restart; the WebUI shows that requirement next to
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
dir="$HOME/.nanobot"
|
dir="$HOME/.nanobot"
|
||||||
|
|
||||||
|
# Render deploy path (see render.yaml + render-config.json). Gated on Render's
|
||||||
|
# automatic RENDER=true env var so local Docker/podman usage is unaffected.
|
||||||
|
# Initializes the on-disk config from the committed template (wiring secrets via
|
||||||
|
# ${VAR} env vars, keeping runtime data on the persistent disk) and appends the
|
||||||
|
# --config flag. Logs each decision so a failed start is diagnosable in Render's
|
||||||
|
# logs. Privilege dropping is handled below, for every root start (not just here).
|
||||||
|
if [ "$RENDER" = "true" ]; then
|
||||||
|
echo "[entrypoint] Render deploy — starting as $(id)"
|
||||||
|
mkdir -p "$dir" || echo "[entrypoint] warning: mkdir $dir failed"
|
||||||
|
config="$dir/config.json"
|
||||||
|
# Initialize config only when it does not already exist, so WebUI/provider
|
||||||
|
# settings edited at runtime survive restarts. The disk persists config.json
|
||||||
|
# across deploys; overwriting it every boot would discard those changes.
|
||||||
|
if [ ! -f "$config" ]; then
|
||||||
|
echo "[entrypoint] initializing $config from render-config.json"
|
||||||
|
cp /app/render-config.json "$config" || echo "[entrypoint] warning: cp config failed"
|
||||||
|
else
|
||||||
|
echo "[entrypoint] existing $config found — leaving it in place"
|
||||||
|
fi
|
||||||
|
set -- "$@" --config "$config"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Drop privileges whenever the container starts as root. Render mounts the
|
||||||
|
# persistent disk root-owned, and a plain `docker run` also defaults to root now,
|
||||||
|
# so this covers both. Chown the data dir so the non-root user can write it, then
|
||||||
|
# re-exec as nanobot. Fail closed: if the privilege drop cannot be performed,
|
||||||
|
# exit rather than run the agent as root.
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
chown -R nanobot:nanobot "$dir" 2>/dev/null || echo "[entrypoint] warning: chown $dir failed"
|
||||||
|
if setpriv --reuid=nanobot --regid=nanobot --init-groups true 2>/dev/null; then
|
||||||
|
echo "[entrypoint] dropping privileges to nanobot via setpriv"
|
||||||
|
exec setpriv --reuid=nanobot --regid=nanobot --init-groups nanobot "$@"
|
||||||
|
fi
|
||||||
|
echo "[entrypoint] error: started as root but setpriv privilege drop failed — refusing to run as root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Already non-root: make sure the data dir is writable before starting.
|
||||||
if [ -d "$dir" ] && [ ! -w "$dir" ]; then
|
if [ -d "$dir" ] && [ ! -w "$dir" ]; then
|
||||||
owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null)
|
owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null)
|
||||||
cat >&2 <<EOF
|
cat >&2 <<EOF
|
||||||
@@ -12,4 +51,5 @@ Fix (pick one):
|
|||||||
EOF
|
EOF
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec nanobot "$@"
|
exec nanobot "$@"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 657 KiB |
@@ -425,9 +425,14 @@ class ContextGovernor:
|
|||||||
return system_messages + self._legal_history_tail(kept, non_system)
|
return system_messages + self._legal_history_tail(kept, non_system)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _summary_for(message: dict[str, Any]) -> str:
|
def _tool_result_compaction_message(message: dict[str, Any]) -> str:
|
||||||
name = message.get("name", "tool")
|
name = message.get("name", "tool")
|
||||||
return f"[Prior {name} result compacted to fit context; the tool call already completed.]"
|
return (
|
||||||
|
f"Error: The previous {name} result was compacted to fit context because it was too "
|
||||||
|
"large. Do not repeat the same call unchanged. Retry with a narrower path, query, "
|
||||||
|
"range, or result limit, use another tool, or tell the user the task cannot fit in "
|
||||||
|
"the available context."
|
||||||
|
)
|
||||||
|
|
||||||
def _legal_history_tail(
|
def _legal_history_tail(
|
||||||
self,
|
self,
|
||||||
@@ -462,12 +467,12 @@ class ContextGovernor:
|
|||||||
tool_call_id = msg.get("tool_call_id")
|
tool_call_id = msg.get("tool_call_id")
|
||||||
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
|
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
|
||||||
continue
|
continue
|
||||||
summary = self._summary_for(msg)
|
compaction_message = self._tool_result_compaction_message(msg)
|
||||||
if msg.get("content") == summary:
|
if msg.get("content") == compaction_message:
|
||||||
continue
|
continue
|
||||||
if updated is messages:
|
if updated is messages:
|
||||||
updated = [dict(m) for m in messages]
|
updated = [dict(m) for m in messages]
|
||||||
updated[idx]["content"] = summary
|
updated[idx]["content"] = compaction_message
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
def _inflight_compaction_candidates(
|
def _inflight_compaction_candidates(
|
||||||
@@ -500,4 +505,4 @@ class ContextGovernor:
|
|||||||
return primary + fallback
|
return primary + fallback
|
||||||
|
|
||||||
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
||||||
messages[idx]["content"] = self._summary_for(messages[idx])
|
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from nanobot.agent.model_runtime import ModelRuntimeResolver
|
|||||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||||
|
from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
@@ -80,6 +81,7 @@ from nanobot.session.manager import (
|
|||||||
replay_max_messages_for_context,
|
replay_max_messages_for_context,
|
||||||
)
|
)
|
||||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
||||||
|
from nanobot.utils.cancellation import task_is_cancelling
|
||||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||||
from nanobot.utils.helpers import image_placeholder_text
|
from nanobot.utils.helpers import image_placeholder_text
|
||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
@@ -355,6 +357,7 @@ class AgentLoop:
|
|||||||
# One file-read/write tracker per logical session. The tool registry is
|
# One file-read/write tracker per logical session. The tool registry is
|
||||||
# shared by this loop, so tools resolve the active state via contextvars.
|
# shared by this loop, so tools resolve the active state via contextvars.
|
||||||
self._file_state_store = FileStateStore()
|
self._file_state_store = FileStateStore()
|
||||||
|
self._exec_session_manager = ExecSessionManager()
|
||||||
self.runner = AgentRunner()
|
self.runner = AgentRunner()
|
||||||
self.subagents = SubagentManager(
|
self.subagents = SubagentManager(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -540,6 +543,7 @@ class AgentLoop:
|
|||||||
bus=self.bus,
|
bus=self.bus,
|
||||||
subagent_manager=self.subagents,
|
subagent_manager=self.subagents,
|
||||||
cron_service=self.cron_service,
|
cron_service=self.cron_service,
|
||||||
|
exec_session_manager=self._exec_session_manager,
|
||||||
sessions=self.sessions,
|
sessions=self.sessions,
|
||||||
provider_snapshot_loader=provider_snapshot_loader,
|
provider_snapshot_loader=provider_snapshot_loader,
|
||||||
image_generation_provider_configs=self._image_generation_provider_configs,
|
image_generation_provider_configs=self._image_generation_provider_configs,
|
||||||
@@ -995,8 +999,11 @@ class AgentLoop:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Preserve real task cancellation so shutdown can complete cleanly.
|
# Preserve real task cancellation so shutdown can complete cleanly.
|
||||||
# Only ignore non-task CancelledError signals that may leak from integrations.
|
# Only ignore non-task CancelledError signals that may leak from integrations.
|
||||||
if not self._running or asyncio.current_task().cancelling():
|
if not self._running or task_is_cancelling():
|
||||||
raise
|
raise
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring leaked CancelledError while consuming inbound messages"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Error consuming inbound message: {}, continuing...", e)
|
logger.warning("Error consuming inbound message: {}, continuing...", e)
|
||||||
|
|||||||
+21
-21
@@ -29,6 +29,12 @@ from nanobot.utils.helpers import (
|
|||||||
truncate_text_to_tokens,
|
truncate_text_to_tokens,
|
||||||
)
|
)
|
||||||
from nanobot.utils.prompt_templates import render_template
|
from nanobot.utils.prompt_templates import render_template
|
||||||
|
from nanobot.utils.workspace_prompts import (
|
||||||
|
WORKSPACE_PROMPT_MAX_CHARS,
|
||||||
|
has_workspace_prompt_override,
|
||||||
|
load_workspace_prompt_override,
|
||||||
|
workspace_prompt_file,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
@@ -492,14 +498,10 @@ class MemoryStore:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def dream_prompt_file(self) -> Path:
|
def dream_prompt_file(self) -> Path:
|
||||||
return self.workspace / "prompts" / "dream.md"
|
return workspace_prompt_file(self.workspace, "dream")
|
||||||
|
|
||||||
def has_dream_prompt_override(self) -> bool:
|
def has_dream_prompt_override(self) -> bool:
|
||||||
with suppress(OSError):
|
return has_workspace_prompt_override(self.dream_prompt_file)
|
||||||
return self.dream_prompt_file.is_file() and bool(
|
|
||||||
self.dream_prompt_file.read_text(encoding="utf-8").strip()
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_dream_prompt() -> str:
|
def default_dream_prompt() -> str:
|
||||||
@@ -512,20 +514,19 @@ class MemoryStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _dream_template(self) -> str:
|
def _dream_template(self) -> str:
|
||||||
with suppress(OSError):
|
text, original_chars = load_workspace_prompt_override(self.dream_prompt_file)
|
||||||
text = self.dream_prompt_file.read_text(encoding="utf-8")
|
if text is not None:
|
||||||
if text.strip():
|
if (
|
||||||
text = text.rstrip()
|
original_chars > WORKSPACE_PROMPT_MAX_CHARS
|
||||||
if len(text) > _DREAM_PROMPT_MAX_CHARS:
|
and not self._dream_prompt_oversize_logged
|
||||||
if not self._dream_prompt_oversize_logged:
|
):
|
||||||
self._dream_prompt_oversize_logged = True
|
self._dream_prompt_oversize_logged = True
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"workspace Dream prompt exceeds {} chars ({}); truncating. "
|
"workspace Dream prompt exceeds {} chars ({}); truncating. "
|
||||||
"Further occurrences suppressed.",
|
"Further occurrences suppressed.",
|
||||||
_DREAM_PROMPT_MAX_CHARS, len(text),
|
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
|
||||||
)
|
)
|
||||||
return truncate_text(text, _DREAM_PROMPT_MAX_CHARS)
|
return text
|
||||||
return text
|
|
||||||
return self.default_dream_prompt()
|
return self.default_dream_prompt()
|
||||||
|
|
||||||
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
|
||||||
@@ -734,7 +735,6 @@ class MemoryStore:
|
|||||||
# that catches any new caller that forgot to set its own cap.
|
# that catches any new caller that forgot to set its own cap.
|
||||||
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
|
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
|
||||||
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
|
_ARCHIVE_SUMMARY_MAX_CHARS = 8_000 # LLM-produced consolidation summary
|
||||||
_DREAM_PROMPT_MAX_CHARS = 32_000 # workspace-local Dream prompt override
|
|
||||||
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
_HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+18
-11
@@ -806,11 +806,17 @@ class AgentRunner:
|
|||||||
else:
|
else:
|
||||||
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
||||||
|
|
||||||
# Streaming requests already have provider-level idle timeouts
|
# Streaming requests also have provider-level idle timeouts
|
||||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
|
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
|
||||||
# LLM timeout here, or healthy long reasoning streams can be killed just
|
# very slow deltas can still run forever. Use a more generous wall-clock
|
||||||
# because total elapsed time exceeded NANOBOT_LLM_TIMEOUT_S.
|
# timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an
|
||||||
outer_timeout_s = None if (wants_streaming or wants_progress_streaming) else timeout_s
|
# opt-out for all LLM wall-clock timeouts.
|
||||||
|
is_streaming_request = wants_streaming or wants_progress_streaming
|
||||||
|
outer_timeout_s = (
|
||||||
|
max(300.0, timeout_s * 2)
|
||||||
|
if is_streaming_request and timeout_s is not None
|
||||||
|
else timeout_s
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response = (
|
response = (
|
||||||
await coro if outer_timeout_s is None
|
await coro if outer_timeout_s is None
|
||||||
@@ -818,16 +824,17 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
if outer_timeout_s is None:
|
if outer_timeout_s is None:
|
||||||
return LLMResponse(
|
response = LLMResponse(
|
||||||
content="Error calling LLM: stream stalled",
|
content="Error calling LLM: stream stalled",
|
||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
error_kind="timeout",
|
error_kind="timeout",
|
||||||
)
|
)
|
||||||
return LLMResponse(
|
else:
|
||||||
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
response = LLMResponse(
|
||||||
finish_reason="error",
|
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
||||||
error_kind="timeout",
|
finish_reason="error",
|
||||||
)
|
error_kind="timeout",
|
||||||
|
)
|
||||||
if progress_state and progress_state.get("reasoning_open"):
|
if progress_state and progress_state.get("reasoning_open"):
|
||||||
await hook.emit_reasoning_end()
|
await hook.emit_reasoning_end()
|
||||||
dropped, all_dropped, original_finish_reason = (
|
dropped, all_dropped, original_finish_reason = (
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from nanobot.agent.tools.context import (
|
|||||||
bind_request_context,
|
bind_request_context,
|
||||||
reset_request_context,
|
reset_request_context,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.tools.exec_session import ExecSessionManager
|
||||||
from nanobot.agent.tools.file_state import FileStates
|
from nanobot.agent.tools.file_state import FileStates
|
||||||
from nanobot.agent.tools.loader import ToolLoader
|
from nanobot.agent.tools.loader import ToolLoader
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
@@ -143,6 +144,7 @@ class SubagentManager:
|
|||||||
else defaults.fail_on_tool_error
|
else defaults.fail_on_tool_error
|
||||||
)
|
)
|
||||||
self.runner = AgentRunner()
|
self.runner = AgentRunner()
|
||||||
|
self._exec_session_manager = ExecSessionManager()
|
||||||
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
|
||||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
@@ -204,6 +206,7 @@ class SubagentManager:
|
|||||||
ctx = ToolContext(
|
ctx = ToolContext(
|
||||||
config=cfg,
|
config=cfg,
|
||||||
workspace=str(root.resolve()),
|
workspace=str(root.resolve()),
|
||||||
|
exec_session_manager=self._exec_session_manager,
|
||||||
file_state_store=FileStates(),
|
file_state_store=FileStates(),
|
||||||
workspace_sandbox=workspace_sandbox_status(
|
workspace_sandbox=workspace_sandbox_status(
|
||||||
restrict_to_workspace=cfg.restrict_to_workspace,
|
restrict_to_workspace=cfg.restrict_to_workspace,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class ToolContext:
|
|||||||
bus: Any | None = None
|
bus: Any | None = None
|
||||||
subagent_manager: Any | None = None
|
subagent_manager: Any | None = None
|
||||||
cron_service: Any | None = None
|
cron_service: Any | None = None
|
||||||
|
exec_session_manager: Any | None = None
|
||||||
sessions: Any | None = None
|
sessions: Any | None = None
|
||||||
file_state_store: Any = field(default=None)
|
file_state_store: Any = field(default=None)
|
||||||
provider_snapshot_loader: Callable[[], Any] | None = None
|
provider_snapshot_loader: Callable[[], Any] | None = None
|
||||||
|
|||||||
@@ -250,11 +250,7 @@ class ExecSessionManager:
|
|||||||
session = self._sessions.get(session_id)
|
session = self._sessions.get(session_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
raise KeyError(session_id)
|
raise KeyError(session_id)
|
||||||
if (
|
if session.owner_session_key and session.owner_session_key != owner_session_key:
|
||||||
owner_session_key
|
|
||||||
and session.owner_session_key
|
|
||||||
and session.owner_session_key != owner_session_key
|
|
||||||
):
|
|
||||||
raise KeyError(session_id)
|
raise KeyError(session_id)
|
||||||
|
|
||||||
if chars:
|
if chars:
|
||||||
@@ -296,9 +292,7 @@ class ExecSessionManager:
|
|||||||
owner_session_key=session.owner_session_key,
|
owner_session_key=session.owner_session_key,
|
||||||
)
|
)
|
||||||
for session_id, session in sorted(self._sessions.items())
|
for session_id, session in sorted(self._sessions.items())
|
||||||
if not owner_session_key
|
if session.owner_session_key == owner_session_key
|
||||||
or not session.owner_session_key
|
|
||||||
or session.owner_session_key == owner_session_key
|
|
||||||
]
|
]
|
||||||
|
|
||||||
async def _cleanup_locked(self) -> None:
|
async def _cleanup_locked(self) -> None:
|
||||||
@@ -442,7 +436,7 @@ class WriteStdinTool(Tool):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
return cls()
|
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exclusive(self) -> bool:
|
def exclusive(self) -> bool:
|
||||||
@@ -586,7 +580,7 @@ class ListExecSessionsTool(Tool):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
return cls()
|
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class ImageGenerationTool(Tool):
|
|||||||
"api_base": provider.api_base if provider else None,
|
"api_base": provider.api_base if provider else None,
|
||||||
"extra_headers": provider.extra_headers if provider else None,
|
"extra_headers": provider.extra_headers if provider else None,
|
||||||
"extra_body": provider.extra_body if provider else None,
|
"extra_body": provider.extra_body if provider else None,
|
||||||
|
"proxy": provider.proxy if provider else None,
|
||||||
}
|
}
|
||||||
return cls(**kwargs)
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from nanobot.security.network import (
|
|||||||
resolve_url_target,
|
resolve_url_target,
|
||||||
validate_url_target,
|
validate_url_target,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.cancellation import task_is_cancelling
|
||||||
|
|
||||||
# Transient connection errors that warrant a single retry.
|
# Transient connection errors that warrant a single retry.
|
||||||
# These typically happen when an MCP server restarts or a network
|
# These typically happen when an MCP server restarts or a network
|
||||||
@@ -487,8 +488,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
|
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
|
||||||
# Re-raise only if our task was externally cancelled (e.g. /stop).
|
# Re-raise only if our task was externally cancelled (e.g. /stop).
|
||||||
task = asyncio.current_task()
|
if task_is_cancelling():
|
||||||
if task is not None and task.cancelling() > 0:
|
|
||||||
raise
|
raise
|
||||||
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
|
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
|
||||||
return ToolResult.error("(MCP tool call was cancelled)")
|
return ToolResult.error("(MCP tool call was cancelled)")
|
||||||
@@ -650,8 +650,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
|
|||||||
)
|
)
|
||||||
return f"(MCP resource read timed out after {self._resource_timeout}s)"
|
return f"(MCP resource read timed out after {self._resource_timeout}s)"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
task = asyncio.current_task()
|
if task_is_cancelling():
|
||||||
if task is not None and task.cancelling() > 0:
|
|
||||||
raise
|
raise
|
||||||
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
|
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
|
||||||
return "(MCP resource read was cancelled)"
|
return "(MCP resource read was cancelled)"
|
||||||
@@ -764,8 +763,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
)
|
)
|
||||||
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
|
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
task = asyncio.current_task()
|
if task_is_cancelling():
|
||||||
if task is not None and task.cancelling() > 0:
|
|
||||||
raise
|
raise
|
||||||
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
||||||
return "(MCP prompt call was cancelled)"
|
return "(MCP prompt call was cancelled)"
|
||||||
@@ -1145,6 +1143,8 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
|
|||||||
else:
|
else:
|
||||||
logger.warning("No MCP servers connected successfully (will retry next message)")
|
logger.warning("No MCP servers connected successfully (will retry next message)")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
if task_is_cancelling():
|
||||||
|
raise
|
||||||
logger.warning("MCP connection cancelled (will retry next message)")
|
logger.warning("MCP connection cancelled (will retry next message)")
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
|
||||||
@@ -1410,6 +1410,10 @@ async def _close_server(state: Any, server_name: str) -> None:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if task_is_cancelling():
|
||||||
|
raise
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||||
except (RuntimeError, BaseExceptionGroup):
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
|
||||||
|
|
||||||
@@ -1423,5 +1427,9 @@ async def close_mcp_servers(state: Any) -> None:
|
|||||||
for name, connection in connections:
|
for name, connection in connections:
|
||||||
try:
|
try:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if task_is_cancelling():
|
||||||
|
raise
|
||||||
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||||
except (RuntimeError, BaseExceptionGroup):
|
except (RuntimeError, BaseExceptionGroup):
|
||||||
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ class ExecTool(Tool):
|
|||||||
allowed_env_keys=cfg.allowed_env_keys,
|
allowed_env_keys=cfg.allowed_env_keys,
|
||||||
allow_patterns=cfg.allow_patterns,
|
allow_patterns=cfg.allow_patterns,
|
||||||
deny_patterns=cfg.deny_patterns,
|
deny_patterns=cfg.deny_patterns,
|
||||||
|
session_manager=getattr(ctx, "exec_session_manager", None),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -535,7 +536,12 @@ class ExecTool(Tool):
|
|||||||
env=cmd_env,
|
env=cmd_env,
|
||||||
)
|
)
|
||||||
command = ExecTool._normalize_powershell_command(command)
|
command = ExecTool._normalize_powershell_command(command)
|
||||||
command = f"{command}\nif ($LASTEXITCODE -ne $null) {{ exit $LASTEXITCODE }}"
|
command = (
|
||||||
|
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
|
||||||
|
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
|
||||||
|
f"{command}\n"
|
||||||
|
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
|
||||||
|
)
|
||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
program, "-NoProfile", "-NonInteractive", "-Command", command,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
|
|||||||
+38
-9
@@ -41,6 +41,26 @@ __all__ = (
|
|||||||
|
|
||||||
API_SESSION_KEY = "api:default"
|
API_SESSION_KEY = "api:default"
|
||||||
API_CHAT_ID = "default"
|
API_CHAT_ID = "default"
|
||||||
|
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
|
||||||
|
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
|
||||||
|
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
|
||||||
|
_SESSION_LOCKS_KEY = web.AppKey[dict]("session_locks")
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
def _app_value(
|
||||||
|
app: Any,
|
||||||
|
key: web.AppKey[Any],
|
||||||
|
legacy_key: str,
|
||||||
|
default: Any = _MISSING,
|
||||||
|
) -> Any:
|
||||||
|
"""Read typed aiohttp state while accepting lightweight dict test doubles."""
|
||||||
|
try:
|
||||||
|
return app[key]
|
||||||
|
except KeyError:
|
||||||
|
if default is _MISSING:
|
||||||
|
return app[legacy_key]
|
||||||
|
return app.get(legacy_key, default)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -209,9 +229,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
if not isinstance(content_type, str):
|
if not isinstance(content_type, str):
|
||||||
content_type = ""
|
content_type = ""
|
||||||
|
|
||||||
agent_loop = request.app["agent_loop"]
|
agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
|
||||||
timeout_s: float = request.app.get("request_timeout", 120.0)
|
timeout_s: float = _app_value(
|
||||||
model_name: str = request.app.get("model_name", "nanobot")
|
request.app,
|
||||||
|
_REQUEST_TIMEOUT_KEY,
|
||||||
|
"request_timeout",
|
||||||
|
120.0,
|
||||||
|
)
|
||||||
|
model_name: str = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
|
||||||
|
|
||||||
stream = False
|
stream = False
|
||||||
try:
|
try:
|
||||||
@@ -238,7 +263,11 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
return _error_json(400, f"Only configured model '{model_name}' is available")
|
return _error_json(400, f"Only configured model '{model_name}' is available")
|
||||||
|
|
||||||
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
|
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
|
||||||
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
|
session_locks: dict[str, asyncio.Lock] = _app_value(
|
||||||
|
request.app,
|
||||||
|
_SESSION_LOCKS_KEY,
|
||||||
|
"session_locks",
|
||||||
|
)
|
||||||
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
|
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -366,7 +395,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
async def handle_models(request: web.Request) -> web.Response:
|
async def handle_models(request: web.Request) -> web.Response:
|
||||||
"""GET /v1/models"""
|
"""GET /v1/models"""
|
||||||
model_name = request.app.get("model_name", "nanobot")
|
model_name = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"object": "list",
|
"object": "list",
|
||||||
@@ -407,10 +436,10 @@ def create_app(
|
|||||||
api_key: Optional API key for Bearer-token authentication on API routes.
|
api_key: Optional API key for Bearer-token authentication on API routes.
|
||||||
"""
|
"""
|
||||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||||
app["agent_loop"] = agent_loop
|
app[_AGENT_LOOP_KEY] = agent_loop
|
||||||
app["model_name"] = model_name
|
app[_MODEL_NAME_KEY] = model_name
|
||||||
app["request_timeout"] = request_timeout
|
app[_REQUEST_TIMEOUT_KEY] = request_timeout
|
||||||
app["session_locks"] = {} # per-user locks, keyed by session_key
|
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ _BRAND_ALIASES: dict[str, str] = {
|
|||||||
"lark-cli": "feishu",
|
"lark-cli": "feishu",
|
||||||
"minimax-cli": "minimax",
|
"minimax-cli": "minimax",
|
||||||
"obsidian-cli": "obsidian",
|
"obsidian-cli": "obsidian",
|
||||||
|
"obsidian-agent": "obsidian",
|
||||||
|
"obsidian-agent-cli": "obsidian",
|
||||||
"slay-the-spire-2": "slay-the-spire-ii",
|
"slay-the-spire-2": "slay-the-spire-ii",
|
||||||
"slay-the-spire-ii": "slay-the-spire-ii",
|
"slay-the-spire-ii": "slay-the-spire-ii",
|
||||||
"unimol-tools": "unimol-tools",
|
"unimol-tools": "unimol-tools",
|
||||||
@@ -761,19 +763,30 @@ class CliAppManager:
|
|||||||
|
|
||||||
def installed_payload(self) -> dict[str, Any]:
|
def installed_payload(self) -> dict[str, Any]:
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
|
cached_apps, _ = self.catalog(cache_only=True)
|
||||||
|
cached_by_name = {
|
||||||
|
str(app.get("name") or "").lower(): app
|
||||||
|
for app in cached_apps
|
||||||
|
if app.get("name")
|
||||||
|
}
|
||||||
rows = []
|
rows = []
|
||||||
for name, raw_entry in sorted(installed.items()):
|
for name, raw_entry in sorted(installed.items()):
|
||||||
entry = raw_entry if isinstance(raw_entry, dict) else {}
|
entry = raw_entry if isinstance(raw_entry, dict) else {}
|
||||||
strategy = str(entry.get("strategy") or "bundled")
|
strategy = str(entry.get("strategy") or "bundled")
|
||||||
|
cached_app = cached_by_name.get(str(name).lower(), {})
|
||||||
app = {
|
app = {
|
||||||
"name": str(name),
|
"name": str(name),
|
||||||
"display_name": str(entry.get("display_name") or name),
|
"display_name": str(
|
||||||
"category": str(entry.get("category") or "installed"),
|
cached_app.get("display_name") or entry.get("display_name") or name
|
||||||
"description": str(entry.get("description") or ""),
|
),
|
||||||
"requires": str(entry.get("requires") or ""),
|
"category": str(cached_app.get("category") or entry.get("category") or "installed"),
|
||||||
|
"description": str(cached_app.get("description") or entry.get("description") or ""),
|
||||||
|
"requires": str(cached_app.get("requires") or entry.get("requires") or ""),
|
||||||
"_source": str(entry.get("source") or "local"),
|
"_source": str(entry.get("source") or "local"),
|
||||||
"entry_point": str(entry.get("entry_point") or ""),
|
"entry_point": str(entry.get("entry_point") or ""),
|
||||||
"package_manager": strategy,
|
"package_manager": strategy,
|
||||||
|
"logo_url": cached_app.get("logo_url") or entry.get("logo_url"),
|
||||||
|
"brand_color": cached_app.get("brand_color") or entry.get("brand_color"),
|
||||||
}
|
}
|
||||||
rows.append(self._app_payload(app, installed))
|
rows.append(self._app_payload(app, installed))
|
||||||
return {
|
return {
|
||||||
@@ -948,6 +961,8 @@ class CliAppManager:
|
|||||||
argv,
|
argv,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
|
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
|
||||||
@@ -966,6 +981,17 @@ class CliAppManager:
|
|||||||
"strategy": strategy,
|
"strategy": strategy,
|
||||||
"installed_at": int(_now()),
|
"installed_at": int(_now()),
|
||||||
}
|
}
|
||||||
|
for field in (
|
||||||
|
"display_name",
|
||||||
|
"category",
|
||||||
|
"description",
|
||||||
|
"requires",
|
||||||
|
"logo_url",
|
||||||
|
"brand_color",
|
||||||
|
):
|
||||||
|
value = app.get(field)
|
||||||
|
if value not in (None, ""):
|
||||||
|
entry[field] = value
|
||||||
resolved = shutil.which(entry_point) if entry_point else None
|
resolved = shutil.which(entry_point) if entry_point else None
|
||||||
if resolved:
|
if resolved:
|
||||||
entry["entry_point_path"] = resolved
|
entry["entry_point_path"] = resolved
|
||||||
@@ -1340,6 +1366,8 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
cwd=str(cwd),
|
cwd=str(cwd),
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=effective_timeout,
|
timeout=effective_timeout,
|
||||||
env=os.environ.copy(),
|
env=os.environ.copy(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Chat channels module with plugin architecture."""
|
"""Shared contracts for chat channels."""
|
||||||
|
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.channels.manager import ChannelManager
|
|
||||||
|
|
||||||
__all__ = ["BaseChannel", "ChannelManager"]
|
__all__ = ["BaseChannel"]
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Small constructors shared by declarative channel manifests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.channels.contracts import ChannelFieldSpec, FieldKind, SetupRequirement
|
||||||
|
|
||||||
|
GROUP_POLICIES = frozenset({"mention", "open", "allowlist"})
|
||||||
|
DIRECT_GROUP_POLICIES = frozenset({"mention", "open"})
|
||||||
|
|
||||||
|
|
||||||
|
def field(
|
||||||
|
kind: FieldKind = "string",
|
||||||
|
*,
|
||||||
|
choices: Iterable[str] = (),
|
||||||
|
default: Any = None,
|
||||||
|
writable: bool = True,
|
||||||
|
snapshot: bool = True,
|
||||||
|
) -> ChannelFieldSpec:
|
||||||
|
return ChannelFieldSpec(
|
||||||
|
kind=kind,
|
||||||
|
choices=frozenset(choices),
|
||||||
|
default=default,
|
||||||
|
writable=writable,
|
||||||
|
snapshot=snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def required(name: str) -> SetupRequirement:
|
||||||
|
return SetupRequirement.field(name)
|
||||||
|
|
||||||
|
|
||||||
|
def required_fields(*names: str) -> tuple[SetupRequirement, ...]:
|
||||||
|
return tuple(required(name) for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
def one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||||
|
return SetupRequirement.one_of(*alternatives)
|
||||||
+15
-335
@@ -1,343 +1,23 @@
|
|||||||
"""Shared channel setup contract for configuration, display, and validation."""
|
"""Resolve channel-owned setup contracts for settings consumers."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
from nanobot.channels.contracts import ChannelSetupSpec
|
||||||
RouteFieldType = str | tuple[str, set[str]]
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
def channel_setup_spec(
|
||||||
class ChannelFieldSpec:
|
name: str,
|
||||||
"""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",
|
|
||||||
*,
|
*,
|
||||||
choices: set[str] | None = None,
|
plugin: ChannelPlugin | None = None,
|
||||||
writable: bool = True,
|
) -> ChannelSetupSpec | None:
|
||||||
snapshot: bool = True,
|
"""Return the setup contract declared by one channel descriptor."""
|
||||||
) -> ChannelFieldSpec:
|
if plugin is None:
|
||||||
return ChannelFieldSpec(
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
kind=kind,
|
|
||||||
choices=frozenset(choices or ()),
|
|
||||||
writable=writable,
|
|
||||||
snapshot=snapshot,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
plugin = load_channel_plugin(name)
|
||||||
def _required(field: str) -> SetupRequirement:
|
return plugin.setup
|
||||||
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)
|
|
||||||
|
|||||||
@@ -224,9 +224,17 @@ class BaseChannel(ABC):
|
|||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
is_dm: bool = False,
|
is_dm: bool = False,
|
||||||
|
authorization_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle an incoming message: check permissions, issue pairing codes in DMs, or forward to bus."""
|
"""Handle a message after checking its authorization subject.
|
||||||
if not self.is_allowed(sender_id):
|
|
||||||
|
``sender_id`` is the identity recorded on the inbound message. Channels
|
||||||
|
where access is scoped to another entity (for example, a group or room)
|
||||||
|
can pass that entity as ``authorization_id`` without changing the
|
||||||
|
sender's identity. When omitted, authorization remains sender-based.
|
||||||
|
"""
|
||||||
|
permission_id = authorization_id if authorization_id is not None else sender_id
|
||||||
|
if not self.is_allowed(permission_id):
|
||||||
if is_dm:
|
if is_dm:
|
||||||
code = generate_code(self.name, str(sender_id))
|
code = generate_code(self.name, str(sender_id))
|
||||||
await self.send(
|
await self.send(
|
||||||
@@ -270,6 +278,16 @@ class BaseChannel(ABC):
|
|||||||
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
|
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
|
||||||
return {"enabled": False}
|
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
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if the channel is running."""
|
"""Check if the channel is running."""
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Small contract shared by channel-owned interactive connection flows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
QueryParams = Mapping[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelConnectError(Exception):
|
||||||
|
"""User-facing channel connection failure."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
def query_first(query: QueryParams, key: str) -> str | None:
|
||||||
|
values = query.get(key)
|
||||||
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ChannelConnectError", "QueryParams", "query_first"]
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
"""Stable contracts shared by channel runtimes and management surfaces."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Literal
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
|
|
||||||
|
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||||
|
RouteFieldType = str | tuple[str, set[str]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ChannelValidationContext:
|
||||||
|
"""Host policy passed to package-owned setup validators."""
|
||||||
|
|
||||||
|
allow_local_service_access: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
|
||||||
|
DefaultConfigFactory = Callable[[], dict[str, Any]]
|
||||||
|
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
|
||||||
|
InstanceConfigUpdater = Callable[..., dict[str, Any]]
|
||||||
|
RuntimeNameFactory = Callable[[str, str], str]
|
||||||
|
FeatureInstancesFactory = Callable[..., list[dict[str, Any]] | None]
|
||||||
|
LocalStatePresent = Callable[[Any], bool]
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChannelActivation",
|
||||||
|
"ChannelFieldSpec",
|
||||||
|
"ChannelInstanceSpec",
|
||||||
|
"ChannelManagementSpec",
|
||||||
|
"ChannelSetupSpec",
|
||||||
|
"ChannelValidationContext",
|
||||||
|
"SetupRequirement",
|
||||||
|
"channel_feature_instances",
|
||||||
|
"channel_default_config",
|
||||||
|
"channel_field_value",
|
||||||
|
"channel_instance_config",
|
||||||
|
"channel_instance_specs",
|
||||||
|
"channel_local_state_present",
|
||||||
|
"channel_runtime_name",
|
||||||
|
"resolve_channel_action_target",
|
||||||
|
"channel_set_config_enabled",
|
||||||
|
"channel_update_instance_config",
|
||||||
|
"channel_value_present",
|
||||||
|
"refresh_channel_feature_metadata",
|
||||||
|
"stringify_channel_value",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelActivation:
|
||||||
|
"""Normalized enablement state used before a channel runtime is imported.
|
||||||
|
|
||||||
|
Channel configuration may be a Pydantic model or persisted JSON, and a
|
||||||
|
channel may expose independently enabled instances. Instance envelopes are
|
||||||
|
opt-in so a channel can keep using an ``instances``
|
||||||
|
field as ordinary channel-owned configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool | None = None
|
||||||
|
instances: tuple["ChannelActivation", ...] | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(
|
||||||
|
cls,
|
||||||
|
section: Any,
|
||||||
|
*,
|
||||||
|
include_instances: bool = False,
|
||||||
|
) -> "ChannelActivation":
|
||||||
|
values = _config_mapping(section)
|
||||||
|
if values is None:
|
||||||
|
raw_enabled = getattr(section, "enabled", _MISSING)
|
||||||
|
return cls(enabled=None if raw_enabled is _MISSING else bool(raw_enabled))
|
||||||
|
|
||||||
|
raw_enabled = values.get("enabled", _MISSING)
|
||||||
|
raw_instances = values.get("instances", _MISSING) if include_instances else _MISSING
|
||||||
|
instances = (
|
||||||
|
tuple(
|
||||||
|
cls.from_config(item, include_instances=True)
|
||||||
|
for item in raw_instances
|
||||||
|
if _config_mapping(item) is not None
|
||||||
|
)
|
||||||
|
if isinstance(raw_instances, list)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return cls(
|
||||||
|
enabled=None if raw_enabled is _MISSING else bool(raw_enabled),
|
||||||
|
instances=instances,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve(self, *, default: bool = False) -> bool:
|
||||||
|
"""Return whether the section contains at least one enabled runtime."""
|
||||||
|
inherited = default if self.enabled is None else self.enabled
|
||||||
|
if self.instances is None:
|
||||||
|
return inherited
|
||||||
|
return any(instance.resolve(default=inherited) for instance in self.instances)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelFieldSpec:
|
||||||
|
"""One channel field exposed through the settings contract."""
|
||||||
|
|
||||||
|
kind: FieldKind = "string"
|
||||||
|
choices: frozenset[str] = frozenset()
|
||||||
|
default: Any = None
|
||||||
|
writable: bool = True
|
||||||
|
snapshot: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def route_type(self) -> RouteFieldType:
|
||||||
|
if self.kind == "enum":
|
||||||
|
return ("enum", set(self.choices))
|
||||||
|
return self.kind
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SetupRequirement:
|
||||||
|
"""A requirement satisfied by any one complete field group."""
|
||||||
|
|
||||||
|
alternatives: tuple[tuple[str, ...], ...]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def field(cls, name: str) -> "SetupRequirement":
|
||||||
|
"""Require one field."""
|
||||||
|
return cls(((name,),))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def one_of(cls, *alternatives: tuple[str, ...]) -> "SetupRequirement":
|
||||||
|
"""Require one complete alternative field group."""
|
||||||
|
return cls(alternatives)
|
||||||
|
|
||||||
|
def is_satisfied(self, values: Any) -> bool:
|
||||||
|
return any(
|
||||||
|
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||||
|
for group in self.alternatives
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simple_field(self) -> str | None:
|
||||||
|
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||||
|
return self.alternatives[0][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelSetupSpec:
|
||||||
|
"""Writable setup fields, requirements, and optional validation."""
|
||||||
|
|
||||||
|
fields: dict[str, ChannelFieldSpec]
|
||||||
|
required: tuple[SetupRequirement, ...] = ()
|
||||||
|
official_url: str | None = None
|
||||||
|
validator: SetupValidator | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secrets(self) -> frozenset[str]:
|
||||||
|
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snapshot_fields(self) -> tuple[str, ...]:
|
||||||
|
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||||
|
return {
|
||||||
|
name: field.route_type
|
||||||
|
for name, field in self.fields.items()
|
||||||
|
if field.writable
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simple_required_fields(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
field
|
||||||
|
for requirement in self.required
|
||||||
|
if (field := requirement.simple_field) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_configured(self, values: Any) -> bool:
|
||||||
|
return bool(self.required) and all(
|
||||||
|
requirement.is_satisfied(values) for requirement in self.required
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
|
||||||
|
"""Serialize the writable setup contract for generic WebUI consumers."""
|
||||||
|
simple_required = set(self.simple_required_fields)
|
||||||
|
fields = []
|
||||||
|
for name, field in self.fields.items():
|
||||||
|
if not field.writable:
|
||||||
|
continue
|
||||||
|
public_field = {
|
||||||
|
"key": f"channels.{channel_name}.{name}",
|
||||||
|
"field": name,
|
||||||
|
"kind": field.kind,
|
||||||
|
"choices": sorted(field.choices),
|
||||||
|
"required": name in simple_required,
|
||||||
|
}
|
||||||
|
if field.default is not None:
|
||||||
|
public_field["default_value"] = stringify_channel_value(field.default)
|
||||||
|
fields.append(public_field)
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"fields": fields,
|
||||||
|
}
|
||||||
|
if self.official_url:
|
||||||
|
payload["official_url"] = self.official_url
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelInstanceSpec:
|
||||||
|
"""One independently managed runtime instance."""
|
||||||
|
|
||||||
|
instance_id: str
|
||||||
|
config: Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelManagementSpec:
|
||||||
|
"""Dependency-free adapter for persisted channel state.
|
||||||
|
|
||||||
|
Runtime classes own network and message lifecycle only. A multi-instance
|
||||||
|
channel supplies these callbacks from a module that can be imported without
|
||||||
|
its optional platform SDK.
|
||||||
|
"""
|
||||||
|
|
||||||
|
multi_instance: bool = False
|
||||||
|
default_config: DefaultConfigFactory | None = None
|
||||||
|
instance_specs: InstanceSpecsFactory | None = None
|
||||||
|
update_instance_config: InstanceConfigUpdater | None = None
|
||||||
|
runtime_name: RuntimeNameFactory | None = None
|
||||||
|
feature_instances: FeatureInstancesFactory | None = None
|
||||||
|
local_state_present: LocalStatePresent | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
multi_instance_callbacks = {
|
||||||
|
"instance_specs": self.instance_specs,
|
||||||
|
"update_instance_config": self.update_instance_config,
|
||||||
|
"runtime_name": self.runtime_name,
|
||||||
|
"feature_instances": self.feature_instances,
|
||||||
|
}
|
||||||
|
if not self.multi_instance:
|
||||||
|
unexpected = [
|
||||||
|
name for name, callback in multi_instance_callbacks.items() if callback is not None
|
||||||
|
]
|
||||||
|
if unexpected:
|
||||||
|
raise ValueError(
|
||||||
|
"single-instance channel management cannot define "
|
||||||
|
+ ", ".join(unexpected)
|
||||||
|
)
|
||||||
|
if self.multi_instance and self.instance_specs is None:
|
||||||
|
raise ValueError("multi-instance channel management requires instance_specs")
|
||||||
|
if self.multi_instance and self.update_instance_config is None:
|
||||||
|
raise ValueError("multi-instance channel management requires update_instance_config")
|
||||||
|
|
||||||
|
|
||||||
|
def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
|
||||||
|
from nanobot.config.loader import merge_missing_defaults
|
||||||
|
|
||||||
|
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
|
||||||
|
if plugin.setup is not None:
|
||||||
|
for name, field in plugin.setup.fields.items():
|
||||||
|
value = field.default
|
||||||
|
if value is None:
|
||||||
|
value = {
|
||||||
|
"string": "",
|
||||||
|
"secret": "",
|
||||||
|
"list": [],
|
||||||
|
"bool": False,
|
||||||
|
}.get(field.kind, _MISSING)
|
||||||
|
if value is not _MISSING:
|
||||||
|
_assign_channel_field(defaults, name, deepcopy(value))
|
||||||
|
|
||||||
|
factory = plugin.management.default_config
|
||||||
|
if factory is None:
|
||||||
|
return defaults
|
||||||
|
values = factory()
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
|
||||||
|
return merge_missing_defaults(values, defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
|
||||||
|
target = values
|
||||||
|
parts = field.split(".")
|
||||||
|
for part in parts[:-1]:
|
||||||
|
nested = target.get(part)
|
||||||
|
if not isinstance(nested, dict):
|
||||||
|
nested = {}
|
||||||
|
target[part] = nested
|
||||||
|
target = nested
|
||||||
|
target[parts[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
|
def channel_local_state_present(plugin: ChannelPlugin, section: Any) -> bool:
|
||||||
|
checker = plugin.management.local_state_present
|
||||||
|
return bool(checker and checker(section))
|
||||||
|
|
||||||
|
|
||||||
|
def channel_runtime_name(plugin: ChannelPlugin, instance_id: str = "default") -> str:
|
||||||
|
factory = plugin.management.runtime_name
|
||||||
|
if factory is None:
|
||||||
|
if instance_id not in {"", "default"}:
|
||||||
|
raise ValueError(f"{plugin.name} does not support multiple instances")
|
||||||
|
runtime_name = plugin.name
|
||||||
|
else:
|
||||||
|
runtime_name = str(factory(plugin.name, instance_id))
|
||||||
|
_validate_runtime_name(plugin, runtime_name)
|
||||||
|
return runtime_name
|
||||||
|
|
||||||
|
|
||||||
|
def channel_instance_specs(
|
||||||
|
plugin: ChannelPlugin,
|
||||||
|
section: Any,
|
||||||
|
*,
|
||||||
|
enabled_only: bool = True,
|
||||||
|
) -> list[ChannelInstanceSpec]:
|
||||||
|
"""Expand persisted config through the dependency-free management adapter."""
|
||||||
|
factory = plugin.management.instance_specs
|
||||||
|
if factory is None:
|
||||||
|
activation = ChannelActivation.from_config(section)
|
||||||
|
raw_specs: Iterable[ChannelInstanceSpec] = (
|
||||||
|
[]
|
||||||
|
if enabled_only and not activation.resolve(default=plugin.default_enabled)
|
||||||
|
else [ChannelInstanceSpec(instance_id="default", config=section)]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raw_specs = factory(section, enabled_only=enabled_only)
|
||||||
|
if not isinstance(raw_specs, Iterable):
|
||||||
|
raise TypeError(
|
||||||
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
|
||||||
|
)
|
||||||
|
specs = list(raw_specs)
|
||||||
|
|
||||||
|
instance_ids: set[str] = set()
|
||||||
|
runtime_names: set[str] = set()
|
||||||
|
for spec in specs:
|
||||||
|
if not isinstance(spec, ChannelInstanceSpec):
|
||||||
|
raise TypeError(
|
||||||
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
|
||||||
|
)
|
||||||
|
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
|
||||||
|
raise ValueError(
|
||||||
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
|
||||||
|
)
|
||||||
|
if spec.instance_id in instance_ids:
|
||||||
|
raise ValueError(
|
||||||
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate instance id "
|
||||||
|
f"'{spec.instance_id}'"
|
||||||
|
)
|
||||||
|
runtime_name = channel_runtime_name(plugin, spec.instance_id)
|
||||||
|
if runtime_name in runtime_names:
|
||||||
|
raise ValueError(
|
||||||
|
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate runtime name "
|
||||||
|
f"'{runtime_name}'"
|
||||||
|
)
|
||||||
|
instance_ids.add(spec.instance_id)
|
||||||
|
runtime_names.add(runtime_name)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_channel_action_target(
|
||||||
|
requested_instance_id: str | None,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve a feature action to an explicit or default instance."""
|
||||||
|
return (requested_instance_id or "").strip() or "default"
|
||||||
|
|
||||||
|
|
||||||
|
def channel_instance_config(
|
||||||
|
plugin: ChannelPlugin,
|
||||||
|
section: Any,
|
||||||
|
*,
|
||||||
|
instance_id: str = "default",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return editable config for one instance."""
|
||||||
|
selected = next(
|
||||||
|
(
|
||||||
|
spec
|
||||||
|
for spec in channel_instance_specs(plugin, section, enabled_only=False)
|
||||||
|
if spec.instance_id == instance_id
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if selected is None:
|
||||||
|
return {}
|
||||||
|
config = selected.config
|
||||||
|
if hasattr(config, "model_dump"):
|
||||||
|
return dict(config.model_dump(mode="json", by_alias=True))
|
||||||
|
return dict(config) if isinstance(config, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def channel_update_instance_config(
|
||||||
|
plugin: ChannelPlugin,
|
||||||
|
section: Any,
|
||||||
|
values: dict[str, Any],
|
||||||
|
*,
|
||||||
|
instance_id: str = "default",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updater = plugin.management.update_instance_config
|
||||||
|
if updater is None:
|
||||||
|
if instance_id not in {"", "default"}:
|
||||||
|
raise ValueError(f"{plugin.name} does not support multiple instances")
|
||||||
|
return values
|
||||||
|
return updater(section, values, instance_id=instance_id)
|
||||||
|
|
||||||
|
|
||||||
|
def channel_set_config_enabled(
|
||||||
|
plugin: ChannelPlugin,
|
||||||
|
section: Any,
|
||||||
|
enabled: bool,
|
||||||
|
*,
|
||||||
|
instance_id: str = "default",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Toggle one instance while preserving channel-owned config shape."""
|
||||||
|
from nanobot.config.loader import merge_missing_defaults
|
||||||
|
|
||||||
|
values = channel_instance_config(plugin, section, instance_id=instance_id)
|
||||||
|
values = merge_missing_defaults(values, channel_default_config(plugin))
|
||||||
|
values["enabled"] = enabled
|
||||||
|
return channel_update_instance_config(
|
||||||
|
plugin,
|
||||||
|
section,
|
||||||
|
values,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def channel_feature_instances(
|
||||||
|
plugin: ChannelPlugin,
|
||||||
|
section: Any,
|
||||||
|
*,
|
||||||
|
setup_spec: ChannelSetupSpec | None = None,
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
factory = plugin.management.feature_instances
|
||||||
|
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None
|
||||||
|
if overrides is None and not plugin.management.multi_instance:
|
||||||
|
return None
|
||||||
|
if overrides is not None and (
|
||||||
|
not isinstance(overrides, list)
|
||||||
|
or any(not isinstance(instance, dict) for instance in overrides)
|
||||||
|
):
|
||||||
|
raise TypeError(
|
||||||
|
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||||
|
"must return a list of dicts or None"
|
||||||
|
)
|
||||||
|
|
||||||
|
enabled_ids = {
|
||||||
|
spec.instance_id for spec in channel_instance_specs(plugin, section, enabled_only=True)
|
||||||
|
}
|
||||||
|
|
||||||
|
instances = [
|
||||||
|
_channel_feature_instance(
|
||||||
|
plugin.name,
|
||||||
|
spec,
|
||||||
|
setup_spec,
|
||||||
|
enabled=spec.instance_id in enabled_ids,
|
||||||
|
)
|
||||||
|
for spec in channel_instance_specs(plugin, section, enabled_only=False)
|
||||||
|
]
|
||||||
|
if overrides is None:
|
||||||
|
return instances
|
||||||
|
|
||||||
|
by_id = {instance["id"]: instance for instance in instances}
|
||||||
|
seen: set[str] = set()
|
||||||
|
for override in overrides:
|
||||||
|
instance_id = override.get("id")
|
||||||
|
if not isinstance(instance_id, str) or instance_id not in by_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||||
|
"returned unknown instance id "
|
||||||
|
f"'{instance_id}'"
|
||||||
|
)
|
||||||
|
if instance_id in seen:
|
||||||
|
raise ValueError(
|
||||||
|
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
|
||||||
|
"returned duplicate instance id "
|
||||||
|
f"'{instance_id}'"
|
||||||
|
)
|
||||||
|
seen.add(instance_id)
|
||||||
|
for field in ("name", "display_name", "avatar_url"):
|
||||||
|
if field in override:
|
||||||
|
by_id[instance_id][field] = str(override[field] or "")
|
||||||
|
return instances
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_channel_feature_metadata(
|
||||||
|
channel_cls: type[Any],
|
||||||
|
config_path: Path,
|
||||||
|
*,
|
||||||
|
instance_id: str = "default",
|
||||||
|
) -> bool:
|
||||||
|
return bool(channel_cls.refresh_feature_metadata(config_path, instance_id=instance_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
|
||||||
|
channel_name = str(plugin.name).strip()
|
||||||
|
if not channel_name:
|
||||||
|
raise ValueError("ChannelPlugin.name must not be empty")
|
||||||
|
if not isinstance(runtime_name, str) or not runtime_name.strip():
|
||||||
|
raise ValueError(f"ChannelPlugin.management for '{plugin.name}' returned an empty runtime name")
|
||||||
|
if runtime_name != channel_name and not runtime_name.startswith(f"{channel_name}."):
|
||||||
|
raise ValueError(
|
||||||
|
f"ChannelPlugin.management runtime name '{runtime_name}' must be scoped under "
|
||||||
|
f"'{channel_name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||||
|
current = values
|
||||||
|
for part in field_path.split("."):
|
||||||
|
candidates = (part, _camel_to_snake(part))
|
||||||
|
if isinstance(current, dict):
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in current:
|
||||||
|
current = current[candidate]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
for candidate in candidates:
|
||||||
|
if hasattr(current, candidate):
|
||||||
|
current = getattr(current, candidate)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def channel_value_present(value: Any) -> bool:
|
||||||
|
return value not in (None, "", [], {})
|
||||||
|
|
||||||
|
|
||||||
|
def stringify_channel_value(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ", ".join(str(item) for item in value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _channel_feature_instance(
|
||||||
|
channel_name: str,
|
||||||
|
instance: ChannelInstanceSpec,
|
||||||
|
setup_spec: ChannelSetupSpec | None,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
config = instance.config
|
||||||
|
name = str(channel_field_value(config, "name") or instance.instance_id).strip()
|
||||||
|
display_name = str(channel_field_value(config, "displayName") or name).strip()
|
||||||
|
avatar_url = str(channel_field_value(config, "avatarUrl") or "").strip()
|
||||||
|
config_values: dict[str, str] = {}
|
||||||
|
configured_fields: list[str] = []
|
||||||
|
setup_fields = setup_spec.fields.items() if setup_spec else ()
|
||||||
|
for field_name, field_spec in setup_fields:
|
||||||
|
if not field_spec.writable:
|
||||||
|
continue
|
||||||
|
value = channel_field_value(config, field_name)
|
||||||
|
if not channel_value_present(value):
|
||||||
|
continue
|
||||||
|
key = f"channels.{channel_name}.{field_name}"
|
||||||
|
configured_fields.append(key)
|
||||||
|
if field_spec.kind != "secret":
|
||||||
|
config_values[key] = stringify_channel_value(value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": instance.instance_id,
|
||||||
|
"name": name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"avatar_url": avatar_url,
|
||||||
|
"enabled": enabled,
|
||||||
|
"configured": bool(setup_spec and setup_spec.is_configured(config)),
|
||||||
|
"config_values": config_values,
|
||||||
|
"configured_fields": configured_fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _config_mapping(value: Any) -> dict[str, Any] | None:
|
||||||
|
if hasattr(value, "model_dump"):
|
||||||
|
dumped = value.model_dump(mode="json", by_alias=True)
|
||||||
|
return dumped if isinstance(dumped, dict) else None
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _camel_to_snake(value: str) -> str:
|
||||||
|
chars: list[str] = []
|
||||||
|
for char in value:
|
||||||
|
if char.isupper():
|
||||||
|
if chars:
|
||||||
|
chars.append("_")
|
||||||
|
chars.append(char.lower())
|
||||||
|
else:
|
||||||
|
chars.append(char)
|
||||||
|
return "".join(chars)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""DingTalk channel package."""
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""DingTalk management contract."""
|
||||||
|
|
||||||
|
from nanobot.channels._manifest import field, required_fields
|
||||||
|
from nanobot.channels.contracts import ChannelSetupSpec
|
||||||
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
|
|
||||||
|
SETUP_SPEC = ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"clientId": field(),
|
||||||
|
"clientSecret": field("secret"),
|
||||||
|
"allowFrom": field("list"),
|
||||||
|
},
|
||||||
|
required=required_fields("clientId", "clientSecret"),
|
||||||
|
official_url="https://open.dingtalk.com/",
|
||||||
|
)
|
||||||
|
|
||||||
|
PLUGIN = ChannelPlugin(
|
||||||
|
name="dingtalk",
|
||||||
|
display_name="DingTalk",
|
||||||
|
runtime=f"{__package__}.runtime:DingTalkChannel",
|
||||||
|
setup=SETUP_SPEC,
|
||||||
|
dependencies=("dingtalk-stream>=0.24.0,<1.0.0",),
|
||||||
|
webui="webui/index.ts",
|
||||||
|
)
|
||||||
@@ -710,10 +710,11 @@ class DingTalkChannel(BaseChannel):
|
|||||||
"""Send a message through DingTalk."""
|
"""Send a message through DingTalk."""
|
||||||
token = await self._get_access_token()
|
token = await self._get_access_token()
|
||||||
if not token:
|
if not token:
|
||||||
return
|
raise RuntimeError("DingTalk access token unavailable")
|
||||||
|
|
||||||
if msg.content and msg.content.strip():
|
if msg.content and msg.content.strip():
|
||||||
await self._send_markdown_text(token, msg.chat_id, msg.content.strip())
|
if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
|
||||||
|
raise RuntimeError("DingTalk text message was not delivered")
|
||||||
|
|
||||||
for media_ref in msg.media or []:
|
for media_ref in msg.media or []:
|
||||||
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
|
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
|
||||||
@@ -722,11 +723,12 @@ class DingTalkChannel(BaseChannel):
|
|||||||
self.logger.error("media send failed for {}", media_ref)
|
self.logger.error("media send failed for {}", media_ref)
|
||||||
# Send visible fallback so failures are observable by the user.
|
# Send visible fallback so failures are observable by the user.
|
||||||
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
|
||||||
await self._send_markdown_text(
|
if not await self._send_markdown_text(
|
||||||
token,
|
token,
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
f"[Attachment send failed: {filename}]",
|
f"[Attachment send failed: {filename}]",
|
||||||
)
|
):
|
||||||
|
raise RuntimeError("DingTalk attachment fallback was not delivered")
|
||||||
|
|
||||||
async def _on_message(
|
async def _on_message(
|
||||||
self,
|
self,
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the DingTalk channel package."""
|
||||||
+37
-2
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import zipfile
|
import zipfile
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,9 +17,14 @@ except ImportError:
|
|||||||
if not DINGTALK_AVAILABLE:
|
if not DINGTALK_AVAILABLE:
|
||||||
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
|
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.bus.queue import MessageBus
|
||||||
from nanobot.channels.dingtalk import DingTalkChannel, DingTalkConfig, NanobotDingTalkHandler
|
from nanobot.channels.dingtalk.runtime import (
|
||||||
|
DingTalkChannel,
|
||||||
|
DingTalkConfig,
|
||||||
|
NanobotDingTalkHandler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
class _FakeResponse:
|
||||||
@@ -864,6 +870,35 @@ async def test_send_batch_message_returns_false_on_api_error() -> None:
|
|||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_raises_when_access_token_is_unavailable(monkeypatch) -> None:
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value=None))
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="access token unavailable"):
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_raises_when_text_is_not_delivered(monkeypatch) -> None:
|
||||||
|
channel = DingTalkChannel(
|
||||||
|
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value="token"))
|
||||||
|
monkeypatch.setattr(channel, "_send_markdown_text", AsyncMock(return_value=False))
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="text message was not delivered"):
|
||||||
|
await channel.send(
|
||||||
|
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
|
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
|
||||||
"""When the first send fails with a transport error, _send_media_ref must
|
"""When the first send fails with a transport error, _send_media_ref must
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.channels.validation import validate_channel_config
|
||||||
|
from nanobot.config.loader import save_config
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(
|
||||||
|
Config.model_validate(
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"dingtalk": {
|
||||||
|
"clientId": "ding-client",
|
||||||
|
"clientSecret": "ding-secret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
config_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
|
result = validate_channel_config("dingtalk", {})
|
||||||
|
|
||||||
|
assert result["status"] == "configured"
|
||||||
|
assert result["can_enable"] is True
|
||||||
|
assert any(check["status"] == "skipped" for check in result["checks"])
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||||
|
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
presentation: {
|
||||||
|
displayName: "DingTalk",
|
||||||
|
initials: "DT",
|
||||||
|
color: "#1677FF",
|
||||||
|
logoUrl:
|
||||||
|
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
|
||||||
|
setup: {
|
||||||
|
mode: "credentials",
|
||||||
|
docsUrl: chatAppGuideUrl("dingtalk"),
|
||||||
|
fields: [
|
||||||
|
{ key: "channels.dingtalk.clientId" },
|
||||||
|
{ key: "channels.dingtalk.clientSecret" },
|
||||||
|
{ key: "channels.dingtalk.allowFrom" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ChannelUiContribution;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "Use nanobot from DingTalk groups.",
|
||||||
|
"requirements": "DingTalk app credentials and gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Open DingTalk setup",
|
||||||
|
"officialLabel": "Open DingTalk console",
|
||||||
|
"tryIt": "Send a test message from the DingTalk group where the app is installed.",
|
||||||
|
"summary": "DingTalk needs app credentials from Stream mode.",
|
||||||
|
"steps": [
|
||||||
|
"Create or choose a DingTalk app with Stream mode enabled.",
|
||||||
|
"Add Client ID and Client Secret.",
|
||||||
|
"Save and enable DingTalk, then send a test message."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "DingTalk client ID",
|
||||||
|
"help": "Copy it from DingTalk app credentials."
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "Copy it from the same DingTalk app credentials page."
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Allowed users",
|
||||||
|
"placeholder": "User IDs, comma separated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "Usa nanobot desde grupos de DingTalk.",
|
||||||
|
"requirements": "Credenciales de la app de DingTalk y gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Abrir guía de DingTalk",
|
||||||
|
"officialLabel": "Abrir consola de DingTalk",
|
||||||
|
"tryIt": "Envía un mensaje de prueba desde el grupo de DingTalk donde está instalada la app.",
|
||||||
|
"summary": "DingTalk necesita credenciales de una app en modo Stream.",
|
||||||
|
"steps": [
|
||||||
|
"Crea o elige una app de DingTalk con el modo Stream activado.",
|
||||||
|
"Añade el Client ID y el Client Secret.",
|
||||||
|
"Guarda y activa DingTalk; después envía un mensaje de prueba."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "Client ID de DingTalk",
|
||||||
|
"help": "Cópialo de las credenciales de la app de DingTalk."
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "Cópialo de la misma página de credenciales."
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Usuarios permitidos",
|
||||||
|
"placeholder": "ID de usuario separados por comas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "Utilisez nanobot depuis les groupes DingTalk.",
|
||||||
|
"requirements": "Identifiants d’application DingTalk et passerelle",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Ouvrir le guide DingTalk",
|
||||||
|
"officialLabel": "Ouvrir la console DingTalk",
|
||||||
|
"tryIt": "Envoyez un message test dans le groupe DingTalk où l’application est installée.",
|
||||||
|
"summary": "DingTalk nécessite les identifiants d’une application en mode Stream.",
|
||||||
|
"steps": [
|
||||||
|
"Créez ou choisissez une application DingTalk avec le mode Stream activé.",
|
||||||
|
"Ajoutez le Client ID et le Client Secret.",
|
||||||
|
"Enregistrez et activez DingTalk, puis envoyez un message test."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "Client ID DingTalk",
|
||||||
|
"help": "Copiez-le depuis les identifiants de l’application DingTalk."
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "Copiez-le depuis la même page d’identifiants DingTalk."
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Utilisateurs autorisés",
|
||||||
|
"placeholder": "ID utilisateur séparés par des virgules"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "Gunakan nanobot dari grup DingTalk.",
|
||||||
|
"requirements": "Kredensial aplikasi DingTalk dan gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Buka panduan DingTalk",
|
||||||
|
"officialLabel": "Buka konsol DingTalk",
|
||||||
|
"tryIt": "Kirim pesan uji dari grup DingTalk tempat aplikasi dipasang.",
|
||||||
|
"summary": "DingTalk memerlukan kredensial aplikasi dari mode Stream.",
|
||||||
|
"steps": [
|
||||||
|
"Buat atau pilih aplikasi DingTalk dengan mode Stream aktif.",
|
||||||
|
"Tambahkan Client ID dan Client Secret.",
|
||||||
|
"Simpan dan aktifkan DingTalk, lalu kirim pesan uji."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "Client ID DingTalk",
|
||||||
|
"help": "Salin dari kredensial aplikasi DingTalk."
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "Salin dari halaman kredensial yang sama."
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Pengguna yang diizinkan",
|
||||||
|
"placeholder": "ID pengguna, dipisahkan koma"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "DingTalk グループから nanobot を利用します。",
|
||||||
|
"requirements": "DingTalk アプリの認証情報とゲートウェイ",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "DingTalk 設定ガイドを開く",
|
||||||
|
"officialLabel": "DingTalk コンソールを開く",
|
||||||
|
"tryIt": "アプリをインストールした DingTalk グループからテストメッセージを送信します。",
|
||||||
|
"summary": "DingTalk には Stream モードのアプリ認証情報が必要です。",
|
||||||
|
"steps": [
|
||||||
|
"Stream モードを有効にした DingTalk アプリを作成または選択します。",
|
||||||
|
"Client ID と Client Secret を追加します。",
|
||||||
|
"保存して DingTalk を有効にし、テストメッセージを送信します。"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "DingTalk Client ID",
|
||||||
|
"help": "DingTalk アプリの認証情報からコピーします。"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "同じ認証情報ページからコピーします。"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "許可するユーザー",
|
||||||
|
"placeholder": "ユーザー ID(カンマ区切り)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "DingTalk 그룹에서 nanobot을 사용합니다.",
|
||||||
|
"requirements": "DingTalk 앱 자격 증명 및 게이트웨이",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "DingTalk 설정 가이드 열기",
|
||||||
|
"officialLabel": "DingTalk 콘솔 열기",
|
||||||
|
"tryIt": "앱이 설치된 DingTalk 그룹에서 테스트 메시지를 보내세요.",
|
||||||
|
"summary": "DingTalk에는 Stream 모드 앱 자격 증명이 필요합니다.",
|
||||||
|
"steps": [
|
||||||
|
"Stream 모드가 활성화된 DingTalk 앱을 만들거나 선택하세요.",
|
||||||
|
"Client ID와 Client Secret을 추가하세요.",
|
||||||
|
"저장하고 DingTalk을 활성화한 다음 테스트 메시지를 보내세요."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "DingTalk Client ID",
|
||||||
|
"help": "DingTalk 앱 자격 증명에서 복사하세요."
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "같은 자격 증명 페이지에서 복사하세요."
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "허용된 사용자",
|
||||||
|
"placeholder": "사용자 ID, 쉼표로 구분"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "Use o nanobot em grupos do DingTalk.",
|
||||||
|
"requirements": "Credenciais do app DingTalk e gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Abrir guia do DingTalk",
|
||||||
|
"officialLabel": "Abrir console do DingTalk",
|
||||||
|
"tryIt": "Envie uma mensagem de teste no grupo do DingTalk onde o app está instalado.",
|
||||||
|
"summary": "O DingTalk precisa das credenciais de um app no modo Stream.",
|
||||||
|
"steps": [
|
||||||
|
"Crie ou escolha um app do DingTalk com o modo Stream ativado.",
|
||||||
|
"Adicione o Client ID e o Client Secret.",
|
||||||
|
"Salve e ative o DingTalk; depois, envie uma mensagem de teste."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "Client ID do DingTalk",
|
||||||
|
"help": "Copie das credenciais do app DingTalk."
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "Copie da mesma página de credenciais."
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Usuários permitidos",
|
||||||
|
"placeholder": "IDs de usuário separados por vírgulas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"description": "Sử dụng nanobot trong các nhóm DingTalk.",
|
||||||
|
"requirements": "Thông tin xác thực ứng dụng DingTalk và gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Mở hướng dẫn DingTalk",
|
||||||
|
"officialLabel": "Mở bảng điều khiển DingTalk",
|
||||||
|
"tryIt": "Gửi tin nhắn thử từ nhóm DingTalk đã cài ứng dụng.",
|
||||||
|
"summary": "DingTalk cần thông tin xác thực ứng dụng ở chế độ Stream.",
|
||||||
|
"steps": [
|
||||||
|
"Tạo hoặc chọn ứng dụng DingTalk đã bật chế độ Stream.",
|
||||||
|
"Thêm Client ID và Client Secret.",
|
||||||
|
"Lưu và bật DingTalk, sau đó gửi tin nhắn thử."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "Client ID DingTalk",
|
||||||
|
"help": "Sao chép từ thông tin xác thực ứng dụng DingTalk."
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "Sao chép từ cùng trang thông tin xác thực."
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Người dùng được phép",
|
||||||
|
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"displayName": "钉钉",
|
||||||
|
"description": "在钉钉群中使用 nanobot。",
|
||||||
|
"requirements": "钉钉应用凭据和网关",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "打开钉钉配置指南",
|
||||||
|
"officialLabel": "打开钉钉开发者后台",
|
||||||
|
"tryIt": "在已安装应用的钉钉群中发送一条测试消息。",
|
||||||
|
"summary": "钉钉需要 Stream 模式的应用凭据。",
|
||||||
|
"steps": [
|
||||||
|
"创建或选择一个已启用 Stream 模式的钉钉应用。",
|
||||||
|
"填写 Client ID 和 Client Secret。",
|
||||||
|
"保存并启用钉钉,然后发送一条测试消息。"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "钉钉 Client ID",
|
||||||
|
"help": "从钉钉应用凭据页面复制。"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "从同一个钉钉应用凭据页面复制。"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "允许的用户",
|
||||||
|
"placeholder": "用户 ID,用逗号分隔"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"displayName": "釘釘",
|
||||||
|
"description": "在釘釘群組中使用 nanobot。",
|
||||||
|
"requirements": "釘釘應用程式憑證和閘道",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "開啟釘釘設定指南",
|
||||||
|
"officialLabel": "開啟釘釘開發者後台",
|
||||||
|
"tryIt": "在已安裝應用程式的釘釘群組中傳送一則測試訊息。",
|
||||||
|
"summary": "釘釘需要 Stream 模式的應用程式憑證。",
|
||||||
|
"steps": [
|
||||||
|
"建立或選擇一個已啟用 Stream 模式的釘釘應用程式。",
|
||||||
|
"填入 Client ID 和 Client Secret。",
|
||||||
|
"儲存並啟用釘釘,然後傳送一則測試訊息。"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"clientId": {
|
||||||
|
"label": "Client ID",
|
||||||
|
"placeholder": "釘釘 Client ID",
|
||||||
|
"help": "從釘釘應用程式憑證頁面複製。"
|
||||||
|
},
|
||||||
|
"clientSecret": {
|
||||||
|
"label": "Client Secret",
|
||||||
|
"placeholder": "••••••",
|
||||||
|
"help": "從同一個釘釘應用程式憑證頁面複製。"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "允許的使用者",
|
||||||
|
"placeholder": "使用者 ID,以逗號分隔"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Discord channel package."""
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Discord management contract."""
|
||||||
|
|
||||||
|
from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field, required
|
||||||
|
from nanobot.channels.contracts import ChannelSetupSpec
|
||||||
|
from nanobot.channels.discord.validation import validate
|
||||||
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
|
|
||||||
|
SETUP_SPEC = ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": field("secret"),
|
||||||
|
"allowFrom": field("list", snapshot=False),
|
||||||
|
"allowChannels": field("list"),
|
||||||
|
"groupPolicy": field("enum", choices=DIRECT_GROUP_POLICIES, default="mention"),
|
||||||
|
},
|
||||||
|
required=(required("token"),),
|
||||||
|
official_url="https://discord.com/developers/applications",
|
||||||
|
validator=validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLUGIN = ChannelPlugin(
|
||||||
|
name="discord",
|
||||||
|
display_name="Discord",
|
||||||
|
runtime=f"{__package__}.runtime:DiscordChannel",
|
||||||
|
setup=SETUP_SPEC,
|
||||||
|
dependencies=("discord.py>=2.5.2,<3.0.0",),
|
||||||
|
webui="webui/index.ts",
|
||||||
|
)
|
||||||
@@ -264,7 +264,7 @@ if DISCORD_AVAILABLE:
|
|||||||
channel = await self.fetch_channel(channel_id)
|
channel = await self.fetch_channel(channel_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
|
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
|
||||||
return
|
raise
|
||||||
|
|
||||||
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
|
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
|
||||||
sent_media = False
|
sent_media = False
|
||||||
@@ -466,8 +466,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
"""Send a message through Discord using discord.py."""
|
"""Send a message through Discord using discord.py."""
|
||||||
client = self._client
|
client = self._client
|
||||||
if client is None or not client.is_ready():
|
if client is None or not client.is_ready():
|
||||||
self.logger.warning("client not ready; dropping outbound message")
|
raise RuntimeError("Discord client is not ready")
|
||||||
return
|
|
||||||
|
|
||||||
is_progress = isinstance(msg.event, ProgressEvent)
|
is_progress = isinstance(msg.event, ProgressEvent)
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Discord channel package."""
|
||||||
+24
-18
@@ -1,3 +1,5 @@
|
|||||||
|
# ruff: noqa: E402
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -12,7 +14,7 @@ import discord
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
from nanobot.bus.outbound_events import ProgressEvent
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.discord import (
|
from nanobot.channels.discord.runtime import (
|
||||||
MAX_MESSAGE_LEN,
|
MAX_MESSAGE_LEN,
|
||||||
DiscordBotClient,
|
DiscordBotClient,
|
||||||
DiscordChannel,
|
DiscordChannel,
|
||||||
@@ -230,7 +232,7 @@ async def test_start_returns_when_discord_dependency_missing(monkeypatch) -> Non
|
|||||||
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
|
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
|
||||||
MessageBus(),
|
MessageBus(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DISCORD_AVAILABLE", False)
|
monkeypatch.setattr("nanobot.channels.discord.runtime.DISCORD_AVAILABLE", False)
|
||||||
|
|
||||||
await channel.start()
|
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):
|
def _boom(owner, *, intents, proxy=None, proxy_auth=None):
|
||||||
raise RuntimeError("bad client")
|
raise RuntimeError("bad client")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
|
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _boom)
|
||||||
|
|
||||||
await channel.start()
|
await channel.start()
|
||||||
|
|
||||||
@@ -267,7 +269,7 @@ async def test_start_handles_client_start_failure(monkeypatch) -> None:
|
|||||||
|
|
||||||
_FakeDiscordClient.instances.clear()
|
_FakeDiscordClient.instances.clear()
|
||||||
_FakeDiscordClient.start_error = RuntimeError("connect failed")
|
_FakeDiscordClient.start_error = RuntimeError("connect failed")
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||||
|
|
||||||
await channel.start()
|
await channel.start()
|
||||||
|
|
||||||
@@ -620,7 +622,7 @@ async def test_on_message_downloads_attachments(tmp_path, monkeypatch) -> None:
|
|||||||
handled.append(kwargs)
|
handled.append(kwargs)
|
||||||
|
|
||||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
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(
|
await channel._on_message(
|
||||||
_make_message(
|
_make_message(
|
||||||
@@ -644,7 +646,7 @@ async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch
|
|||||||
handled.append(kwargs)
|
handled.append(kwargs)
|
||||||
|
|
||||||
channel._handle_message = capture_handle # type: ignore[method-assign]
|
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(
|
await channel._on_message(
|
||||||
_make_message(
|
_make_message(
|
||||||
@@ -659,18 +661,19 @@ async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_warns_when_client_not_ready() -> None:
|
async def test_send_raises_when_client_not_ready() -> None:
|
||||||
# Sending without a running/ready client should be a safe no-op.
|
# The manager must be able to retry while Discord is still connecting.
|
||||||
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||||
|
|
||||||
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
with pytest.raises(RuntimeError, match="client is not ready"):
|
||||||
|
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
||||||
|
|
||||||
assert channel._typing_tasks == {}
|
assert channel._typing_tasks == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_skips_when_channel_not_cached() -> None:
|
async def test_send_raises_when_channel_cannot_be_resolved() -> None:
|
||||||
# Outbound sends should be skipped when the destination channel is not resolvable.
|
# The manager must be able to retry transient channel-resolution failures.
|
||||||
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
|
||||||
client = DiscordBotClient(owner, intents=discord.Intents.none())
|
client = DiscordBotClient(owner, intents=discord.Intents.none())
|
||||||
fetch_calls: list[int] = []
|
fetch_calls: list[int] = []
|
||||||
@@ -681,7 +684,10 @@ async def test_send_skips_when_channel_not_cached() -> None:
|
|||||||
|
|
||||||
client.fetch_channel = fetch_channel # type: ignore[method-assign]
|
client.fetch_channel = fetch_channel # type: ignore[method-assign]
|
||||||
|
|
||||||
await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello"))
|
with pytest.raises(RuntimeError, match="not found"):
|
||||||
|
await client.send_outbound(
|
||||||
|
OutboundMessage(channel="discord", chat_id="123", content="hello")
|
||||||
|
)
|
||||||
|
|
||||||
assert client.get_channel(123) is None
|
assert client.get_channel(123) is None
|
||||||
assert fetch_calls == [123]
|
assert fetch_calls == [123]
|
||||||
@@ -737,7 +743,7 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
|
|||||||
client.channels[123] = target
|
client.channels[123] = target
|
||||||
|
|
||||||
times = iter([1.0, 3.0, 5.0])
|
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", "hel", stream_id="s1")
|
||||||
await owner.send_delta("123", "lo", stream_id="s1")
|
await owner.send_delta("123", "lo", stream_id="s1")
|
||||||
@@ -764,7 +770,7 @@ async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None
|
|||||||
assert len(chunks) == 2
|
assert len(chunks) == 2
|
||||||
|
|
||||||
times = iter([1.0, 3.0])
|
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", prefix, stream_id="s1")
|
||||||
await owner.send_delta("123", suffix, stream_id="s1")
|
await owner.send_delta("123", suffix, stream_id="s1")
|
||||||
@@ -1209,7 +1215,7 @@ async def test_start_passes_proxy_to_client(monkeypatch) -> None:
|
|||||||
),
|
),
|
||||||
MessageBus(),
|
MessageBus(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||||
|
|
||||||
await channel.start()
|
await channel.start()
|
||||||
|
|
||||||
@@ -1234,7 +1240,7 @@ async def test_start_passes_proxy_auth_when_credentials_provided(monkeypatch) ->
|
|||||||
),
|
),
|
||||||
MessageBus(),
|
MessageBus(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||||
|
|
||||||
await channel.start()
|
await channel.start()
|
||||||
|
|
||||||
@@ -1260,7 +1266,7 @@ async def test_start_no_proxy_auth_when_only_username(monkeypatch) -> None:
|
|||||||
),
|
),
|
||||||
MessageBus(),
|
MessageBus(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||||
|
|
||||||
await channel.start()
|
await channel.start()
|
||||||
|
|
||||||
@@ -1281,7 +1287,7 @@ async def test_start_no_proxy_auth_when_only_password(monkeypatch) -> None:
|
|||||||
),
|
),
|
||||||
MessageBus(),
|
MessageBus(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
|
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
|
||||||
|
|
||||||
await channel.start()
|
await channel.start()
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Discord setup validation owned by the channel package."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from nanobot.channels.contracts import ChannelValidationContext
|
||||||
|
from nanobot.channels.validation import (
|
||||||
|
check,
|
||||||
|
http_get,
|
||||||
|
payload,
|
||||||
|
required_checks,
|
||||||
|
status_from_checks,
|
||||||
|
string_value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
|
||||||
|
checks, missing = required_checks("discord", values)
|
||||||
|
token = string_value(values.get("token"))
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
data = http_get(
|
||||||
|
"https://discord.com/api/v10/users/@me",
|
||||||
|
headers={"Authorization": f"Bot {token}"},
|
||||||
|
)
|
||||||
|
bot_id = str(data.get("id") or "")
|
||||||
|
checks.append(check("bot_token", "Bot token", "pass", "Discord accepted the bot token."))
|
||||||
|
identity = {
|
||||||
|
"name": data.get("global_name") or data.get("username"),
|
||||||
|
"account": bot_id,
|
||||||
|
}
|
||||||
|
if bot_id:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
"invite",
|
||||||
|
"Server invite",
|
||||||
|
"pass",
|
||||||
|
"Use this generated OAuth URL to invite the bot.",
|
||||||
|
action_url=(
|
||||||
|
"https://discord.com/oauth2/authorize"
|
||||||
|
f"?client_id={bot_id}&scope=bot%20applications.commands"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return payload(
|
||||||
|
"discord",
|
||||||
|
"connected",
|
||||||
|
checks,
|
||||||
|
identity=identity,
|
||||||
|
missing_fields=missing,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
"bot_token",
|
||||||
|
"Bot token",
|
||||||
|
"fail",
|
||||||
|
f"Discord rejected the token: HTTP {exc.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
checks.append(
|
||||||
|
check("bot_token", "Bot token", "warn", f"Could not reach Discord now: {exc}")
|
||||||
|
)
|
||||||
|
return status_from_checks("discord", checks, missing)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["validate"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||||
|
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
presentation: {
|
||||||
|
displayName: "Discord",
|
||||||
|
initials: "DC",
|
||||||
|
color: "#5865F2",
|
||||||
|
logoUrl: "https://discord.com/favicon.ico",
|
||||||
|
setup: {
|
||||||
|
mode: "credentials",
|
||||||
|
docsUrl: chatAppGuideUrl("discord"),
|
||||||
|
fields: [
|
||||||
|
{ key: "channels.discord.token" },
|
||||||
|
{ key: "channels.discord.allowChannels" },
|
||||||
|
{ key: "channels.discord.groupPolicy" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ChannelUiContribution;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Use nanobot from Discord servers and DMs.",
|
||||||
|
"requirements": "Discord bot token, permissions, gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Open Discord setup",
|
||||||
|
"officialLabel": "Open Discord portal",
|
||||||
|
"tryIt": "Mention the bot in a server or send it a direct message.",
|
||||||
|
"summary": "Enable turns on Discord support. Discord still needs a bot token and server permissions.",
|
||||||
|
"steps": [
|
||||||
|
"Create a bot in Discord Developer Portal and copy its token.",
|
||||||
|
"Invite the bot to your server with message read/send and slash command permissions.",
|
||||||
|
"Save and enable Discord, then mention the bot or send a direct message."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "Bot token",
|
||||||
|
"placeholder": "Discord bot token",
|
||||||
|
"help": "Create it from the Bot page in Discord Developer Portal."
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "Allowed channels",
|
||||||
|
"placeholder": "Channel IDs, comma separated",
|
||||||
|
"help": "Leave empty to allow any channel the bot can read."
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "Group behavior",
|
||||||
|
"choices": {
|
||||||
|
"mention": "Mention only",
|
||||||
|
"open": "All messages",
|
||||||
|
"allowlist": "Allowlist"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Allowed users",
|
||||||
|
"placeholder": "User IDs, comma separated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Usa nanobot en servidores y mensajes directos de Discord.",
|
||||||
|
"requirements": "Token del bot de Discord, permisos y gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Abrir guía de Discord",
|
||||||
|
"officialLabel": "Abrir portal de Discord",
|
||||||
|
"tryIt": "Menciona al bot en un servidor o envíale un mensaje directo.",
|
||||||
|
"summary": "Activar habilita Discord. Aún necesitas el token del bot y permisos del servidor.",
|
||||||
|
"steps": [
|
||||||
|
"Crea un bot en Discord Developer Portal y copia su token.",
|
||||||
|
"Invítalo al servidor con permisos para leer/enviar mensajes y usar comandos slash.",
|
||||||
|
"Guarda y activa Discord; después menciona al bot o envíale un mensaje directo."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "Token del bot",
|
||||||
|
"placeholder": "Token del bot de Discord",
|
||||||
|
"help": "Créalo desde la página Bot de Discord Developer Portal."
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "Canales permitidos",
|
||||||
|
"placeholder": "ID de canal separados por comas",
|
||||||
|
"help": "Déjalo vacío para permitir cualquier canal que el bot pueda leer."
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "Comportamiento en grupos",
|
||||||
|
"choices": {
|
||||||
|
"mention": "Solo menciones",
|
||||||
|
"open": "Todos los mensajes",
|
||||||
|
"allowlist": "Lista permitida"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Usuarios permitidos",
|
||||||
|
"placeholder": "ID de usuario separados por comas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Utilisez nanobot sur les serveurs Discord et en messages privés.",
|
||||||
|
"requirements": "Jeton du bot Discord, permissions et passerelle",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Ouvrir le guide Discord",
|
||||||
|
"officialLabel": "Ouvrir le portail Discord",
|
||||||
|
"tryIt": "Mentionnez le bot sur un serveur ou envoyez-lui un message privé.",
|
||||||
|
"summary": "L’activation ouvre la prise en charge de Discord. Un jeton de bot et des permissions serveur restent nécessaires.",
|
||||||
|
"steps": [
|
||||||
|
"Créez un bot dans le portail développeur Discord et copiez son jeton.",
|
||||||
|
"Invitez-le sur votre serveur avec les permissions de lecture, d’envoi et de commandes slash.",
|
||||||
|
"Enregistrez et activez Discord, puis mentionnez le bot ou envoyez-lui un message privé."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "Jeton du bot",
|
||||||
|
"placeholder": "Jeton du bot Discord",
|
||||||
|
"help": "Créez-le depuis la page Bot du portail développeur Discord."
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "Salons autorisés",
|
||||||
|
"placeholder": "ID de salon séparés par des virgules",
|
||||||
|
"help": "Laissez vide pour autoriser tous les salons lisibles par le bot."
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "Comportement en groupe",
|
||||||
|
"choices": {
|
||||||
|
"mention": "Mentions uniquement",
|
||||||
|
"open": "Tous les messages",
|
||||||
|
"allowlist": "Liste d’autorisation"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Utilisateurs autorisés",
|
||||||
|
"placeholder": "ID utilisateur séparés par des virgules"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Gunakan nanobot dari server dan DM Discord.",
|
||||||
|
"requirements": "Token bot Discord, izin, dan gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Buka panduan Discord",
|
||||||
|
"officialLabel": "Buka portal Discord",
|
||||||
|
"tryIt": "Sebut bot di server atau kirim pesan langsung.",
|
||||||
|
"summary": "Mengaktifkan akan menyalakan dukungan Discord. Token bot dan izin server tetap diperlukan.",
|
||||||
|
"steps": [
|
||||||
|
"Buat bot di Discord Developer Portal dan salin tokennya.",
|
||||||
|
"Undang bot ke server dengan izin baca/kirim pesan dan perintah slash.",
|
||||||
|
"Simpan dan aktifkan Discord, lalu sebut bot atau kirim DM."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "Token bot",
|
||||||
|
"placeholder": "Token bot Discord",
|
||||||
|
"help": "Buat dari halaman Bot di Discord Developer Portal."
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "Channel yang diizinkan",
|
||||||
|
"placeholder": "ID channel, dipisahkan koma",
|
||||||
|
"help": "Kosongkan untuk mengizinkan semua channel yang dapat dibaca bot."
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "Perilaku grup",
|
||||||
|
"choices": {
|
||||||
|
"mention": "Hanya sebutan",
|
||||||
|
"open": "Semua pesan",
|
||||||
|
"allowlist": "Daftar izin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Pengguna yang diizinkan",
|
||||||
|
"placeholder": "ID pengguna, dipisahkan koma"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Discord サーバーと DM から nanobot を利用します。",
|
||||||
|
"requirements": "Discord ボットトークン、権限、ゲートウェイ",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Discord 設定ガイドを開く",
|
||||||
|
"officialLabel": "Discord ポータルを開く",
|
||||||
|
"tryIt": "サーバーでボットをメンションするか、DM を送信します。",
|
||||||
|
"summary": "有効化すると Discord 対応がオンになります。ボットトークンとサーバー権限が必要です。",
|
||||||
|
"steps": [
|
||||||
|
"Discord Developer Portal でボットを作成し、トークンをコピーします。",
|
||||||
|
"メッセージの読み書きとスラッシュコマンド権限を付けてサーバーに招待します。",
|
||||||
|
"保存して Discord を有効にし、メンションまたは DM を送信します。"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "ボットトークン",
|
||||||
|
"placeholder": "Discord ボットトークン",
|
||||||
|
"help": "Discord Developer Portal の Bot ページで作成します。"
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "許可するチャンネル",
|
||||||
|
"placeholder": "チャンネル ID(カンマ区切り)",
|
||||||
|
"help": "空欄の場合、ボットが読めるすべてのチャンネルを許可します。"
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "グループでの動作",
|
||||||
|
"choices": {
|
||||||
|
"mention": "メンションのみ",
|
||||||
|
"open": "すべてのメッセージ",
|
||||||
|
"allowlist": "許可リスト"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "許可するユーザー",
|
||||||
|
"placeholder": "ユーザー ID(カンマ区切り)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Discord 서버와 DM에서 nanobot을 사용합니다.",
|
||||||
|
"requirements": "Discord 봇 토큰, 권한 및 게이트웨이",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Discord 설정 가이드 열기",
|
||||||
|
"officialLabel": "Discord 포털 열기",
|
||||||
|
"tryIt": "서버에서 봇을 멘션하거나 DM을 보내세요.",
|
||||||
|
"summary": "활성화하면 Discord 지원이 켜집니다. 봇 토큰과 서버 권한이 필요합니다.",
|
||||||
|
"steps": [
|
||||||
|
"Discord Developer Portal에서 봇을 만들고 토큰을 복사하세요.",
|
||||||
|
"메시지 읽기/보내기 및 슬래시 명령 권한으로 서버에 초대하세요.",
|
||||||
|
"저장하고 Discord를 활성화한 다음 봇을 멘션하거나 DM을 보내세요."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "봇 토큰",
|
||||||
|
"placeholder": "Discord 봇 토큰",
|
||||||
|
"help": "Discord Developer Portal의 Bot 페이지에서 생성하세요."
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "허용된 채널",
|
||||||
|
"placeholder": "채널 ID, 쉼표로 구분",
|
||||||
|
"help": "비워 두면 봇이 읽을 수 있는 모든 채널을 허용합니다."
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "그룹 동작",
|
||||||
|
"choices": {
|
||||||
|
"mention": "멘션만",
|
||||||
|
"open": "모든 메시지",
|
||||||
|
"allowlist": "허용 목록"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "허용된 사용자",
|
||||||
|
"placeholder": "사용자 ID, 쉼표로 구분"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Use o nanobot em servidores e DMs do Discord.",
|
||||||
|
"requirements": "Token do bot Discord, permissões e gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Abrir guia do Discord",
|
||||||
|
"officialLabel": "Abrir portal do Discord",
|
||||||
|
"tryIt": "Mencione o bot em um servidor ou envie uma mensagem direta.",
|
||||||
|
"summary": "Ativar liga o suporte ao Discord. O token do bot e as permissões do servidor ainda são necessários.",
|
||||||
|
"steps": [
|
||||||
|
"Crie um bot no Discord Developer Portal e copie o token.",
|
||||||
|
"Convide-o para o servidor com permissões de leitura/envio e comandos slash.",
|
||||||
|
"Salve e ative o Discord; depois, mencione o bot ou envie uma DM."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "Token do bot",
|
||||||
|
"placeholder": "Token do bot Discord",
|
||||||
|
"help": "Crie-o na página Bot do Discord Developer Portal."
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "Canais permitidos",
|
||||||
|
"placeholder": "IDs de canal separados por vírgulas",
|
||||||
|
"help": "Deixe vazio para permitir qualquer canal que o bot consiga ler."
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "Comportamento em grupos",
|
||||||
|
"choices": {
|
||||||
|
"mention": "Somente menções",
|
||||||
|
"open": "Todas as mensagens",
|
||||||
|
"allowlist": "Lista de permissão"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Usuários permitidos",
|
||||||
|
"placeholder": "IDs de usuário separados por vírgulas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "Sử dụng nanobot trong máy chủ và tin nhắn riêng Discord.",
|
||||||
|
"requirements": "Token bot Discord, quyền và gateway",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Mở hướng dẫn Discord",
|
||||||
|
"officialLabel": "Mở cổng Discord",
|
||||||
|
"tryIt": "Nhắc bot trong máy chủ hoặc gửi tin nhắn riêng.",
|
||||||
|
"summary": "Bật sẽ kích hoạt hỗ trợ Discord. Bạn vẫn cần token bot và quyền trên máy chủ.",
|
||||||
|
"steps": [
|
||||||
|
"Tạo bot trong Discord Developer Portal và sao chép token.",
|
||||||
|
"Mời bot vào máy chủ với quyền đọc/gửi tin nhắn và lệnh slash.",
|
||||||
|
"Lưu và bật Discord, sau đó nhắc bot hoặc gửi tin nhắn riêng."
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "Token bot",
|
||||||
|
"placeholder": "Token bot Discord",
|
||||||
|
"help": "Tạo từ trang Bot trong Discord Developer Portal."
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "Kênh được phép",
|
||||||
|
"placeholder": "ID kênh, phân tách bằng dấu phẩy",
|
||||||
|
"help": "Để trống để cho phép mọi kênh bot có thể đọc."
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "Hành vi trong nhóm",
|
||||||
|
"choices": {
|
||||||
|
"mention": "Chỉ khi được nhắc",
|
||||||
|
"open": "Mọi tin nhắn",
|
||||||
|
"allowlist": "Danh sách cho phép"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Người dùng được phép",
|
||||||
|
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "在 Discord 服务器和私信中使用 nanobot。",
|
||||||
|
"requirements": "Discord 机器人令牌、权限和网关",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "打开 Discord 配置指南",
|
||||||
|
"officialLabel": "打开 Discord 开发者后台",
|
||||||
|
"tryIt": "在服务器中提及机器人,或向它发送私信。",
|
||||||
|
"summary": "启用只会打开 Discord 支持;还需要机器人令牌和服务器权限。",
|
||||||
|
"steps": [
|
||||||
|
"在 Discord Developer Portal 中创建机器人并复制令牌。",
|
||||||
|
"将机器人邀请到服务器,并授予读取/发送消息及斜杠命令权限。",
|
||||||
|
"保存并启用 Discord,然后提及机器人或发送私信。"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "机器人令牌",
|
||||||
|
"placeholder": "Discord 机器人令牌",
|
||||||
|
"help": "从 Discord Developer Portal 的 Bot 页面创建。"
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "允许的频道",
|
||||||
|
"placeholder": "频道 ID,用逗号分隔",
|
||||||
|
"help": "留空则允许机器人可读取的所有频道。"
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "群组行为",
|
||||||
|
"choices": {
|
||||||
|
"mention": "仅提及时",
|
||||||
|
"open": "所有消息",
|
||||||
|
"allowlist": "白名单"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "允许的用户",
|
||||||
|
"placeholder": "用户 ID,用逗号分隔"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"description": "在 Discord 伺服器和私訊中使用 nanobot。",
|
||||||
|
"requirements": "Discord 機器人權杖、權限和閘道",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "開啟 Discord 設定指南",
|
||||||
|
"officialLabel": "開啟 Discord 開發者後台",
|
||||||
|
"tryIt": "在伺服器中提及機器人,或向它傳送私訊。",
|
||||||
|
"summary": "啟用只會開啟 Discord 支援;還需要機器人權杖和伺服器權限。",
|
||||||
|
"steps": [
|
||||||
|
"在 Discord Developer Portal 中建立機器人並複製權杖。",
|
||||||
|
"將機器人邀請到伺服器,並授予讀取/傳送訊息及斜線指令權限。",
|
||||||
|
"儲存並啟用 Discord,然後提及機器人或傳送私訊。"
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"token": {
|
||||||
|
"label": "機器人權杖",
|
||||||
|
"placeholder": "Discord 機器人權杖",
|
||||||
|
"help": "從 Discord Developer Portal 的 Bot 頁面建立。"
|
||||||
|
},
|
||||||
|
"allowChannels": {
|
||||||
|
"label": "允許的頻道",
|
||||||
|
"placeholder": "頻道 ID,以逗號分隔",
|
||||||
|
"help": "留空則允許機器人可讀取的所有頻道。"
|
||||||
|
},
|
||||||
|
"groupPolicy": {
|
||||||
|
"label": "群組行為",
|
||||||
|
"choices": {
|
||||||
|
"mention": "僅提及時",
|
||||||
|
"open": "所有訊息",
|
||||||
|
"allowlist": "允許清單"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "允許的使用者",
|
||||||
|
"placeholder": "使用者 ID,以逗號分隔"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Email channel package."""
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Email management contract."""
|
||||||
|
|
||||||
|
from nanobot.channels._manifest import field, required_fields
|
||||||
|
from nanobot.channels.contracts import ChannelSetupSpec
|
||||||
|
from nanobot.channels.email.validation import validate
|
||||||
|
from nanobot.channels.plugin import ChannelPlugin
|
||||||
|
|
||||||
|
SETUP_SPEC = ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"consentGranted": field("bool", default=False),
|
||||||
|
"imapHost": field(),
|
||||||
|
"imapPort": field("int", default=993),
|
||||||
|
"imapUsername": field(),
|
||||||
|
"imapPassword": field("secret"),
|
||||||
|
"smtpHost": field(),
|
||||||
|
"smtpPort": field("int", default=587),
|
||||||
|
"smtpUsername": field(),
|
||||||
|
"smtpPassword": field("secret"),
|
||||||
|
"fromAddress": field(),
|
||||||
|
"pollIntervalSeconds": field("int", default=30),
|
||||||
|
"allowFrom": field("list"),
|
||||||
|
"verifyDkim": field("bool", default=True),
|
||||||
|
"verifySpf": field("bool", default=True),
|
||||||
|
},
|
||||||
|
required=required_fields(
|
||||||
|
"consentGranted",
|
||||||
|
"imapHost",
|
||||||
|
"imapUsername",
|
||||||
|
"imapPassword",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpUsername",
|
||||||
|
"smtpPassword",
|
||||||
|
),
|
||||||
|
official_url="https://support.google.com/accounts/answer/185833",
|
||||||
|
validator=validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
PLUGIN = ChannelPlugin(
|
||||||
|
name="email",
|
||||||
|
display_name="Email",
|
||||||
|
runtime=f"{__package__}.runtime:EmailChannel",
|
||||||
|
setup=SETUP_SPEC,
|
||||||
|
webui="webui/index.ts",
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the email channel package."""
|
||||||
+48
-48
@@ -8,7 +8,7 @@ import pytest
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
from nanobot.bus.outbound_events import ProgressEvent
|
||||||
from nanobot.bus.queue import MessageBus
|
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:
|
def _make_config(**overrides) -> EmailConfig:
|
||||||
@@ -77,7 +77,7 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
|
|||||||
return "BYE", [b""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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())
|
channel = EmailChannel(_make_config(), MessageBus())
|
||||||
items, skipped_uids = channel._fetch_new_messages()
|
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):
|
def logout(self):
|
||||||
return "BYE", [b""]
|
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())
|
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||||
items, skipped_uids = channel._fetch_new_messages()
|
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):
|
def logout(self):
|
||||||
return "BYE", [b""]
|
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(
|
channel_skip = EmailChannel(
|
||||||
_make_config(from_address="bot@example.com", post_action="delete", post_action_ignore_skipped=True),
|
_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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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 = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||||
channel._apply_post_actions_batch(["123", "124"])
|
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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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(
|
channel = EmailChannel(
|
||||||
_make_config(post_action="move", post_action_move_mailbox="Processed"),
|
_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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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(
|
channel = EmailChannel(
|
||||||
_make_config(post_action="move", post_action_move_mailbox="Processed"),
|
_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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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 = EmailChannel(_make_config(post_action="delete"), MessageBus())
|
||||||
channel._apply_post_actions_batch(["123", "124"])
|
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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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 = EmailChannel(_make_config(post_action="delete", post_action_expunge=True), MessageBus())
|
||||||
channel._apply_post_actions_batch(["123", "124"])
|
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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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())
|
channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus())
|
||||||
items, skipped_uids = channel._fetch_new_messages()
|
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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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())
|
channel = EmailChannel(_make_config(**config_override), MessageBus())
|
||||||
items, _ = channel._fetch_new_messages()
|
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)
|
fake_instances.append(instance)
|
||||||
return 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())
|
channel = EmailChannel(_make_config(), MessageBus())
|
||||||
items, _ = channel._fetch_new_messages()
|
items, _ = channel._fetch_new_messages()
|
||||||
@@ -732,7 +732,7 @@ def test_fetch_new_messages_keeps_messages_collected_before_stale_retry(monkeypa
|
|||||||
def logout(self):
|
def logout(self):
|
||||||
return "BYE", [b""]
|
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())
|
channel = EmailChannel(_make_config(), MessageBus())
|
||||||
items, _ = channel._fetch_new_messages()
|
items, _ = channel._fetch_new_messages()
|
||||||
@@ -752,7 +752,7 @@ def test_fetch_new_messages_skips_missing_mailbox(monkeypatch) -> None:
|
|||||||
return "BYE", [b""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.email.imaplib.IMAP4_SSL",
|
"nanobot.channels.email.runtime.imaplib.IMAP4_SSL",
|
||||||
lambda _h, _p: MissingMailboxIMAP(),
|
lambda _h, _p: MissingMailboxIMAP(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -827,7 +827,7 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
|
|||||||
fake_instances.append(instance)
|
fake_instances.append(instance)
|
||||||
return 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 = EmailChannel(_make_config(), MessageBus())
|
||||||
channel._last_subject_by_chat["alice@example.com"] = "Invoice #42"
|
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
|
called["smtp"] = True
|
||||||
raise AssertionError("progress messages must not open an SMTP connection")
|
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())
|
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)
|
fake_instances.append(instance)
|
||||||
return 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 = _make_config()
|
||||||
cfg.auto_reply_enabled = False
|
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)
|
fake_instances.append(instance)
|
||||||
return 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 = _make_config()
|
||||||
cfg.auto_reply_enabled = False
|
cfg.auto_reply_enabled = False
|
||||||
@@ -1014,7 +1014,7 @@ async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
|
|||||||
called["smtp"] = True
|
called["smtp"] = True
|
||||||
return FakeSMTP(host, port, timeout=timeout)
|
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 = _make_config()
|
||||||
cfg.consent_granted = False
|
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""]
|
return "BYE", [b""]
|
||||||
|
|
||||||
fake = FakeIMAP()
|
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())
|
channel = EmailChannel(_make_config(), MessageBus())
|
||||||
items = channel.fetch_messages_between_dates(
|
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."""
|
"""An email without Authentication-Results should be rejected when verify_dkim=True."""
|
||||||
raw = _make_raw_email(subject="Spoofed", body="Malicious payload")
|
raw = _make_raw_email(subject="Spoofed", body="Malicious payload")
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(verify_dkim=True, verify_spf=True)
|
||||||
channel = EmailChannel(cfg, MessageBus())
|
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",
|
auth_results="mx.example.com; spf=pass smtp.mailfrom=alice@example.com; dkim=pass header.d=example.com",
|
||||||
)
|
)
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(verify_dkim=True, verify_spf=True)
|
||||||
channel = EmailChannel(cfg, MessageBus())
|
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",
|
auth_results="mx.example.com; spf=pass smtp.mailfrom=alice@example.com; dkim=fail",
|
||||||
)
|
)
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(verify_dkim=True, verify_spf=True)
|
||||||
channel = EmailChannel(cfg, MessageBus())
|
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."""
|
"""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")
|
raw = _make_raw_email(subject="NoAuth", body="No auth headers present")
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(verify_dkim=False, verify_spf=False)
|
||||||
channel = EmailChannel(cfg, MessageBus())
|
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."""
|
"""Email content should be prefixed with [EMAIL-CONTEXT] for LLM isolation."""
|
||||||
raw = _make_raw_email(subject="Tagged", body="Check the tag")
|
raw = _make_raw_email(subject="Tagged", body="Check the tag")
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(verify_dkim=False, verify_spf=False)
|
||||||
channel = EmailChannel(cfg, MessageBus())
|
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:
|
def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None:
|
||||||
raw = _make_raw_email_with_attachment(from_addr="blocked@example.com")
|
raw = _make_raw_email_with_attachment(from_addr="blocked@example.com")
|
||||||
fake = _make_fake_imap(raw)
|
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}
|
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:
|
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
|
||||||
"""PDF attachment is saved to media dir and path returned in media list."""
|
"""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()
|
raw = _make_raw_email_with_attachment()
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(allowed_attachment_types=["application/pdf"], verify_dkim=False, verify_spf=False)
|
||||||
channel = EmailChannel(cfg, MessageBus())
|
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."""
|
"""With no allowed_attachment_types (default), no attachments are extracted."""
|
||||||
raw = _make_raw_email_with_attachment()
|
raw = _make_raw_email_with_attachment()
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(verify_dkim=False, verify_spf=False)
|
||||||
assert cfg.allowed_attachment_types == []
|
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:
|
def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
|
||||||
"""Non-allowed MIME types are skipped."""
|
"""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(
|
raw = _make_raw_email_with_attachment(
|
||||||
attachment_name="image.png",
|
attachment_name="image.png",
|
||||||
@@ -1342,7 +1342,7 @@ def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
|
|||||||
attachment_mime="image/png",
|
attachment_mime="image/png",
|
||||||
)
|
)
|
||||||
fake = _make_fake_imap(raw)
|
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(
|
cfg = _make_config(
|
||||||
allowed_attachment_types=["application/pdf"],
|
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:
|
def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypatch) -> None:
|
||||||
"""Empty allowed_attachment_types means no types are accepted."""
|
"""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(
|
raw = _make_raw_email_with_attachment(
|
||||||
attachment_name="image.png",
|
attachment_name="image.png",
|
||||||
@@ -1366,7 +1366,7 @@ def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypat
|
|||||||
attachment_mime="image/png",
|
attachment_mime="image/png",
|
||||||
)
|
)
|
||||||
fake = _make_fake_imap(raw)
|
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(
|
cfg = _make_config(
|
||||||
allowed_attachment_types=[],
|
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:
|
def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
|
||||||
"""Glob patterns like 'image/*' match attachment MIME types."""
|
"""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(
|
raw = _make_raw_email_with_attachment(
|
||||||
attachment_name="photo.jpg",
|
attachment_name="photo.jpg",
|
||||||
@@ -1390,7 +1390,7 @@ def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
|
|||||||
attachment_mime="image/jpeg",
|
attachment_mime="image/jpeg",
|
||||||
)
|
)
|
||||||
fake = _make_fake_imap(raw)
|
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(
|
cfg = _make_config(
|
||||||
allowed_attachment_types=["image/*"],
|
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:
|
def test_extract_attachments_size_limit(tmp_path, monkeypatch) -> None:
|
||||||
"""Attachments exceeding max_attachment_size are skipped."""
|
"""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(
|
raw = _make_raw_email_with_attachment(
|
||||||
attachment_content=b"x" * 1000,
|
attachment_content=b"x" * 1000,
|
||||||
)
|
)
|
||||||
fake = _make_fake_imap(raw)
|
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(
|
cfg = _make_config(
|
||||||
allowed_attachment_types=["*"],
|
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:
|
def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
|
||||||
"""Only max_attachments_per_email are saved."""
|
"""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
|
# Build email with 3 attachments
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
@@ -1448,7 +1448,7 @@ def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
|
|||||||
raw = msg.as_bytes()
|
raw = msg.as_bytes()
|
||||||
|
|
||||||
fake = _make_fake_imap(raw)
|
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(
|
cfg = _make_config(
|
||||||
allowed_attachment_types=["*"],
|
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:
|
def test_extract_attachments_sanitizes_filename(tmp_path, monkeypatch) -> None:
|
||||||
"""Path traversal in filenames is neutralized."""
|
"""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(
|
raw = _make_raw_email_with_attachment(
|
||||||
attachment_name="../../../etc/passwd",
|
attachment_name="../../../etc/passwd",
|
||||||
)
|
)
|
||||||
fake = _make_fake_imap(raw)
|
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)
|
cfg = _make_config(allowed_attachment_types=["*"], verify_dkim=False, verify_spf=False)
|
||||||
channel = EmailChannel(cfg, MessageBus())
|
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):
|
def send_message(self, msg: EmailMessage):
|
||||||
sent_messages.append(msg)
|
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
|
# Create a real temp file to attach
|
||||||
attachment = tmp_path / "report.pdf"
|
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):
|
def send_message(self, msg: EmailMessage):
|
||||||
sent_messages.append(msg)
|
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 = tmp_path / "doc.pdf"
|
||||||
file1.write_bytes(b"%PDF-1.4 doc")
|
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):
|
def send_message(self, msg: EmailMessage):
|
||||||
sent_messages.append(msg)
|
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 = tmp_path / "real.txt"
|
||||||
existing.write_text("I exist")
|
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):
|
def send_message(self, msg: EmailMessage):
|
||||||
sent_messages.append(msg)
|
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 = tmp_path / "too-large.bin"
|
||||||
attachment.write_bytes(b"1234")
|
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):
|
def send_message(self, msg: EmailMessage):
|
||||||
sent_messages.append(msg)
|
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 = tmp_path / "first.txt"
|
||||||
file1.write_text("first")
|
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):
|
def send_message(self, msg: EmailMessage):
|
||||||
sent_messages.append(msg)
|
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 = tmp_path / "data.unknown_ext_xyz"
|
||||||
attachment.write_bytes(b"some binary data")
|
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):
|
def send_message(self, msg: EmailMessage):
|
||||||
sent_messages.append(msg)
|
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 = tmp_path / "summary.pdf"
|
||||||
attachment.write_bytes(b"%PDF-1.4 summary")
|
attachment.write_bytes(b"%PDF-1.4 summary")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.channels.email import validation as email_validation
|
||||||
|
from nanobot.channels.validation import validate_channel_config
|
||||||
|
from nanobot.config.loader import load_config, save_config
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_email_presets_are_checked_without_saving(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
save_config(Config(), config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
monkeypatch.setattr(email_validation, "probe_tcp", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
|
result = validate_channel_config(
|
||||||
|
"email",
|
||||||
|
{
|
||||||
|
"channels.email.consentGranted": "true",
|
||||||
|
"channels.email.imapHost": "imap.gmail.com",
|
||||||
|
"channels.email.imapUsername": "bot@example.com",
|
||||||
|
"channels.email.imapPassword": "imap-secret",
|
||||||
|
"channels.email.smtpHost": "smtp.gmail.com",
|
||||||
|
"channels.email.smtpUsername": "bot@example.com",
|
||||||
|
"channels.email.smtpPassword": "smtp-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["status"] == "connected"
|
||||||
|
assert result["can_enable"] is True
|
||||||
|
assert not hasattr(load_config(config_path).channels, "email")
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = Config()
|
||||||
|
config.tools.webui_allow_local_service_access = False
|
||||||
|
save_config(config, config_path)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.validation.socket.create_connection",
|
||||||
|
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = validate_channel_config(
|
||||||
|
"email",
|
||||||
|
{
|
||||||
|
"channels.email.consentGranted": "true",
|
||||||
|
"channels.email.imapHost": "127.0.0.1",
|
||||||
|
"channels.email.imapUsername": "bot@example.com",
|
||||||
|
"channels.email.imapPassword": "imap-secret",
|
||||||
|
"channels.email.smtpHost": "192.168.1.10",
|
||||||
|
"channels.email.smtpUsername": "bot@example.com",
|
||||||
|
"channels.email.smtpPassword": "smtp-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
warnings = [check["message"] for check in result["checks"] if check["status"] == "warn"]
|
||||||
|
assert len(warnings) == 2
|
||||||
|
assert all("private/internal" in message for message in warnings)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Email setup validation owned by the channel package."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.channels.contracts import ChannelValidationContext
|
||||||
|
from nanobot.channels.validation import (
|
||||||
|
check,
|
||||||
|
int_value,
|
||||||
|
probe_tcp,
|
||||||
|
required_checks,
|
||||||
|
status_from_checks,
|
||||||
|
string_value,
|
||||||
|
truthy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
values: dict[str, Any],
|
||||||
|
context: ChannelValidationContext,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
checks, missing = required_checks("email", values)
|
||||||
|
if truthy(values.get("consentGranted")):
|
||||||
|
checks.append(check("consent", "Mailbox consent", "pass", "Consent is enabled for this mailbox."))
|
||||||
|
else:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
"consent",
|
||||||
|
"Mailbox consent",
|
||||||
|
"fail",
|
||||||
|
"Grant consent before nanobot reads this mailbox.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for prefix, default_port in (("imap", 993), ("smtp", 587)):
|
||||||
|
host = string_value(values.get(f"{prefix}Host"))
|
||||||
|
port = int_value(values.get(f"{prefix}Port")) or default_port
|
||||||
|
if not host:
|
||||||
|
continue
|
||||||
|
if port <= 0 or port > 65535:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
f"{prefix}_port",
|
||||||
|
f"{prefix.upper()} port",
|
||||||
|
"fail",
|
||||||
|
"Port must be between 1 and 65535.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
f"{prefix}_settings",
|
||||||
|
f"{prefix.upper()} settings",
|
||||||
|
"pass",
|
||||||
|
f"{host}:{port} is set.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
probe_tcp(
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
allow_loopback=context.allow_local_service_access,
|
||||||
|
)
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
f"{prefix}_reachability",
|
||||||
|
f"{prefix.upper()} reachability",
|
||||||
|
"pass",
|
||||||
|
"The server accepted a TCP connection.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
checks.append(
|
||||||
|
check(
|
||||||
|
f"{prefix}_reachability",
|
||||||
|
f"{prefix.upper()} reachability",
|
||||||
|
"warn",
|
||||||
|
f"Could not verify network reachability now: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = {
|
||||||
|
"account": string_value(
|
||||||
|
values.get("fromAddress")
|
||||||
|
or values.get("imapUsername")
|
||||||
|
or values.get("smtpUsername")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return status_from_checks("email", checks, missing, identity=identity)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["validate"]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { ChannelUiContribution } from "@/channel-plugins/types";
|
||||||
|
import {
|
||||||
|
type ChannelProviderPresetDefinition,
|
||||||
|
chatAppGuideUrl,
|
||||||
|
} from "@/components/settings/channels/catalog";
|
||||||
|
|
||||||
|
const EMAIL_PROVIDER_PRESETS: ChannelProviderPresetDefinition[] = [
|
||||||
|
{
|
||||||
|
id: "gmail",
|
||||||
|
values: {
|
||||||
|
"channels.email.imapHost": "imap.gmail.com",
|
||||||
|
"channels.email.imapPort": "993",
|
||||||
|
"channels.email.smtpHost": "smtp.gmail.com",
|
||||||
|
"channels.email.smtpPort": "587",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "outlook",
|
||||||
|
values: {
|
||||||
|
"channels.email.imapHost": "outlook.office365.com",
|
||||||
|
"channels.email.imapPort": "993",
|
||||||
|
"channels.email.smtpHost": "smtp.office365.com",
|
||||||
|
"channels.email.smtpPort": "587",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "icloud",
|
||||||
|
values: {
|
||||||
|
"channels.email.imapHost": "imap.mail.me.com",
|
||||||
|
"channels.email.imapPort": "993",
|
||||||
|
"channels.email.smtpHost": "smtp.mail.me.com",
|
||||||
|
"channels.email.smtpPort": "587",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: "custom", values: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
presentation: {
|
||||||
|
displayName: "Email",
|
||||||
|
initials: "EM",
|
||||||
|
color: "#64748B",
|
||||||
|
logoUrl: "https://gmail.com/favicon.ico",
|
||||||
|
setup: {
|
||||||
|
mode: "credentials",
|
||||||
|
docsUrl: chatAppGuideUrl("email"),
|
||||||
|
presets: EMAIL_PROVIDER_PRESETS,
|
||||||
|
fields: [
|
||||||
|
{ key: "channels.email.consentGranted" },
|
||||||
|
{ key: "channels.email.imapHost" },
|
||||||
|
{ key: "channels.email.imapUsername" },
|
||||||
|
{ key: "channels.email.imapPassword" },
|
||||||
|
{ key: "channels.email.smtpHost" },
|
||||||
|
{ key: "channels.email.smtpUsername" },
|
||||||
|
{ key: "channels.email.smtpPassword" },
|
||||||
|
{ key: "channels.email.imapPort" },
|
||||||
|
{ key: "channels.email.smtpPort" },
|
||||||
|
{ key: "channels.email.fromAddress" },
|
||||||
|
{ key: "channels.email.pollIntervalSeconds" },
|
||||||
|
{ key: "channels.email.allowFrom" },
|
||||||
|
{ key: "channels.email.verifyDkim" },
|
||||||
|
{ key: "channels.email.verifySpf" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ChannelUiContribution;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "Let nanobot receive and answer email messages.",
|
||||||
|
"requirements": "IMAP inbox, SMTP sender, app password, explicit consent",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Open Email setup",
|
||||||
|
"officialLabel": "Open app password guide",
|
||||||
|
"tryIt": "Send a test email to the connected mailbox.",
|
||||||
|
"summary": "Email reads messages over IMAP and replies over SMTP. Use a dedicated mailbox and grant consent before enabling it.",
|
||||||
|
"steps": [
|
||||||
|
"Create a dedicated mailbox and, when required, an app password.",
|
||||||
|
"Choose a provider preset or enter the IMAP and SMTP settings manually.",
|
||||||
|
"Grant consent, save and enable Email, then send a test message to the mailbox."
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "Custom"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "Consent granted",
|
||||||
|
"help": "Required safety switch. Leave false until this bot mailbox is intentionally connected.",
|
||||||
|
"choices": {
|
||||||
|
"true": "Granted",
|
||||||
|
"false": "Not granted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "IMAP host",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "IMAP username",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "IMAP password",
|
||||||
|
"placeholder": "App password",
|
||||||
|
"help": "Use an app password when your mail provider requires one."
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "SMTP host",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "SMTP username",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "SMTP password",
|
||||||
|
"placeholder": "App password",
|
||||||
|
"help": "Usually the same app password used for IMAP."
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "IMAP port",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "SMTP port",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "From address",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "Poll interval",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Allowed senders",
|
||||||
|
"placeholder": "Email addresses, comma separated",
|
||||||
|
"help": "Leave empty to require pairing before a sender can use email."
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "Verify DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "On",
|
||||||
|
"false": "Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "Verify SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "On",
|
||||||
|
"false": "Off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "Permite que nanobot reciba y responda correos.",
|
||||||
|
"requirements": "Bandeja IMAP, envío SMTP, contraseña de app y consentimiento explícito",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Abrir guía de Email",
|
||||||
|
"officialLabel": "Abrir guía de contraseñas de app",
|
||||||
|
"tryIt": "Envía un correo de prueba al buzón conectado.",
|
||||||
|
"summary": "Email lee mensajes por IMAP y responde por SMTP. Usa un buzón dedicado y da tu consentimiento antes de activarlo.",
|
||||||
|
"steps": [
|
||||||
|
"Crea un buzón dedicado y, si hace falta, una contraseña de app.",
|
||||||
|
"Elige un proveedor o introduce manualmente IMAP y SMTP.",
|
||||||
|
"Da tu consentimiento, guarda y activa Email; después envía un mensaje de prueba."
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "Personalizado"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "Consentimiento concedido",
|
||||||
|
"help": "Control de seguridad obligatorio. Déjalo desactivado hasta decidir conectar este buzón al bot.",
|
||||||
|
"choices": {
|
||||||
|
"true": "Concedido",
|
||||||
|
"false": "No concedido"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "Host IMAP",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "Usuario IMAP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "Contraseña IMAP",
|
||||||
|
"placeholder": "Contraseña de app",
|
||||||
|
"help": "Usa una contraseña de app si el proveedor la exige."
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "Host SMTP",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "Usuario SMTP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "Contraseña SMTP",
|
||||||
|
"placeholder": "Contraseña de app",
|
||||||
|
"help": "Normalmente es la misma que para IMAP."
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "Puerto IMAP",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "Puerto SMTP",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "Dirección remitente",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "Intervalo de consulta",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Remitentes permitidos",
|
||||||
|
"placeholder": "Correos separados por comas",
|
||||||
|
"help": "Déjalo vacío para exigir vinculación previa."
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "Verificar DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "Activado",
|
||||||
|
"false": "Desactivado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "Verificar SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "Activado",
|
||||||
|
"false": "Desactivado"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "Permettez à nanobot de recevoir et répondre aux e-mails.",
|
||||||
|
"requirements": "Boîte IMAP, envoi SMTP, mot de passe d’application et consentement explicite",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Ouvrir le guide Email",
|
||||||
|
"officialLabel": "Ouvrir le guide des mots de passe d’application",
|
||||||
|
"tryIt": "Envoyez un e-mail test à la boîte connectée.",
|
||||||
|
"summary": "Email lit les messages via IMAP et répond via SMTP. Utilisez une boîte dédiée et accordez votre consentement avant l’activation.",
|
||||||
|
"steps": [
|
||||||
|
"Créez une boîte dédiée et, si nécessaire, un mot de passe d’application.",
|
||||||
|
"Choisissez un fournisseur ou saisissez les paramètres IMAP et SMTP.",
|
||||||
|
"Accordez le consentement, enregistrez et activez Email, puis envoyez un message test."
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "Personnalisé"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "Consentement accordé",
|
||||||
|
"help": "Sécurité obligatoire. N’activez qu’après avoir choisi de connecter cette boîte au bot.",
|
||||||
|
"choices": {
|
||||||
|
"true": "Accordé",
|
||||||
|
"false": "Non accordé"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "Hôte IMAP",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "Nom d’utilisateur IMAP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "Mot de passe IMAP",
|
||||||
|
"placeholder": "Mot de passe d’application",
|
||||||
|
"help": "Utilisez un mot de passe d’application si le fournisseur l’exige."
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "Hôte SMTP",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "Nom d’utilisateur SMTP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "Mot de passe SMTP",
|
||||||
|
"placeholder": "Mot de passe d’application",
|
||||||
|
"help": "Généralement identique à celui d’IMAP."
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "Port IMAP",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "Port SMTP",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "Adresse d’envoi",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "Intervalle de relève",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Expéditeurs autorisés",
|
||||||
|
"placeholder": "Adresses séparées par des virgules",
|
||||||
|
"help": "Laissez vide pour imposer l’association avant utilisation."
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "Vérifier DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "Activé",
|
||||||
|
"false": "Désactivé"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "Vérifier SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "Activé",
|
||||||
|
"false": "Désactivé"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "Izinkan nanobot menerima dan membalas email.",
|
||||||
|
"requirements": "Kotak masuk IMAP, pengirim SMTP, kata sandi aplikasi, dan persetujuan eksplisit",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Buka panduan Email",
|
||||||
|
"officialLabel": "Buka panduan kata sandi aplikasi",
|
||||||
|
"tryIt": "Kirim email uji ke kotak surat yang terhubung.",
|
||||||
|
"summary": "Email membaca pesan melalui IMAP dan membalas melalui SMTP. Gunakan kotak surat khusus dan berikan persetujuan sebelum mengaktifkan.",
|
||||||
|
"steps": [
|
||||||
|
"Buat kotak surat khusus dan kata sandi aplikasi bila diperlukan.",
|
||||||
|
"Pilih preset penyedia atau masukkan IMAP dan SMTP secara manual.",
|
||||||
|
"Berikan persetujuan, simpan dan aktifkan Email, lalu kirim pesan uji."
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "Kustom"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "Persetujuan diberikan",
|
||||||
|
"help": "Sakelar keamanan wajib. Aktifkan hanya setelah sengaja menghubungkan kotak surat ini ke bot.",
|
||||||
|
"choices": {
|
||||||
|
"true": "Diberikan",
|
||||||
|
"false": "Belum diberikan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "Host IMAP",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "Nama pengguna IMAP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "Kata sandi IMAP",
|
||||||
|
"placeholder": "Kata sandi aplikasi",
|
||||||
|
"help": "Gunakan kata sandi aplikasi jika diwajibkan penyedia."
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "Host SMTP",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "Nama pengguna SMTP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "Kata sandi SMTP",
|
||||||
|
"placeholder": "Kata sandi aplikasi",
|
||||||
|
"help": "Biasanya sama dengan kata sandi aplikasi IMAP."
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "Port IMAP",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "Port SMTP",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "Alamat pengirim",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "Interval pemeriksaan",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Pengirim yang diizinkan",
|
||||||
|
"placeholder": "Alamat email, dipisahkan koma",
|
||||||
|
"help": "Kosongkan untuk mewajibkan pairing terlebih dahulu."
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "Verifikasi DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "Aktif",
|
||||||
|
"false": "Nonaktif"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "Verifikasi SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "Aktif",
|
||||||
|
"false": "Nonaktif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "nanobot でメールを受信し、返信します。",
|
||||||
|
"requirements": "IMAP 受信箱、SMTP 送信、アプリパスワード、明示的な同意",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "メール設定ガイドを開く",
|
||||||
|
"officialLabel": "アプリパスワードガイドを開く",
|
||||||
|
"tryIt": "接続したメールボックスにテストメールを送信します。",
|
||||||
|
"summary": "メールは IMAP で受信し SMTP で返信します。専用メールボックスを使い、有効化前に同意してください。",
|
||||||
|
"steps": [
|
||||||
|
"専用メールボックスを作成し、必要ならアプリパスワードを発行します。",
|
||||||
|
"プロバイダープリセットを選ぶか、IMAP と SMTP を手動入力します。",
|
||||||
|
"同意して保存し、メールを有効にしてテストメールを送信します。"
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "カスタム"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "同意済み",
|
||||||
|
"help": "必須の安全設定です。このボット用メールボックスを接続すると決めるまでオフにしてください。",
|
||||||
|
"choices": {
|
||||||
|
"true": "同意済み",
|
||||||
|
"false": "未同意"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "IMAP ホスト",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "IMAP ユーザー名",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "IMAP パスワード",
|
||||||
|
"placeholder": "アプリパスワード",
|
||||||
|
"help": "プロバイダーが求める場合はアプリパスワードを使います。"
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "SMTP ホスト",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "SMTP ユーザー名",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "SMTP パスワード",
|
||||||
|
"placeholder": "アプリパスワード",
|
||||||
|
"help": "通常は IMAP と同じアプリパスワードです。"
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "IMAP ポート",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "SMTP ポート",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "送信元アドレス",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "確認間隔",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "許可する送信者",
|
||||||
|
"placeholder": "メールアドレス(カンマ区切り)",
|
||||||
|
"help": "空欄の場合、送信者は先にペアリングが必要です。"
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "DKIM を検証",
|
||||||
|
"choices": {
|
||||||
|
"true": "オン",
|
||||||
|
"false": "オフ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "SPF を検証",
|
||||||
|
"choices": {
|
||||||
|
"true": "オン",
|
||||||
|
"false": "オフ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "nanobot이 이메일을 받고 답장하도록 합니다.",
|
||||||
|
"requirements": "IMAP 받은편지함, SMTP 발신, 앱 비밀번호 및 명시적 동의",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "이메일 설정 가이드 열기",
|
||||||
|
"officialLabel": "앱 비밀번호 가이드 열기",
|
||||||
|
"tryIt": "연결된 사서함으로 테스트 이메일을 보내세요.",
|
||||||
|
"summary": "이메일은 IMAP으로 읽고 SMTP로 답장합니다. 전용 사서함을 사용하고 활성화 전에 동의하세요.",
|
||||||
|
"steps": [
|
||||||
|
"전용 사서함을 만들고 필요하면 앱 비밀번호를 생성하세요.",
|
||||||
|
"제공자 프리셋을 선택하거나 IMAP 및 SMTP 설정을 직접 입력하세요.",
|
||||||
|
"동의하고 저장한 뒤 이메일을 활성화하고 테스트 메시지를 보내세요."
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "사용자 지정"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "동의함",
|
||||||
|
"help": "필수 안전 스위치입니다. 이 봇 사서함을 연결하기로 결정하기 전에는 끄세요.",
|
||||||
|
"choices": {
|
||||||
|
"true": "동의함",
|
||||||
|
"false": "동의하지 않음"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "IMAP 호스트",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "IMAP 사용자 이름",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "IMAP 비밀번호",
|
||||||
|
"placeholder": "앱 비밀번호",
|
||||||
|
"help": "메일 제공자가 요구하면 앱 비밀번호를 사용하세요."
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "SMTP 호스트",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "SMTP 사용자 이름",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "SMTP 비밀번호",
|
||||||
|
"placeholder": "앱 비밀번호",
|
||||||
|
"help": "보통 IMAP과 같은 앱 비밀번호를 사용합니다."
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "IMAP 포트",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "SMTP 포트",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "보내는 주소",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "확인 간격",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "허용된 발신자",
|
||||||
|
"placeholder": "이메일 주소, 쉼표로 구분",
|
||||||
|
"help": "비워 두면 발신자가 먼저 페어링해야 합니다."
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "DKIM 확인",
|
||||||
|
"choices": {
|
||||||
|
"true": "켜짐",
|
||||||
|
"false": "꺼짐"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "SPF 확인",
|
||||||
|
"choices": {
|
||||||
|
"true": "켜짐",
|
||||||
|
"false": "꺼짐"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "Permita que o nanobot receba e responda e-mails.",
|
||||||
|
"requirements": "Caixa IMAP, envio SMTP, senha de app e consentimento explícito",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Abrir guia de Email",
|
||||||
|
"officialLabel": "Abrir guia de senhas de app",
|
||||||
|
"tryIt": "Envie um e-mail de teste para a caixa conectada.",
|
||||||
|
"summary": "Email lê mensagens por IMAP e responde por SMTP. Use uma caixa dedicada e dê consentimento antes de ativar.",
|
||||||
|
"steps": [
|
||||||
|
"Crie uma caixa dedicada e, quando necessário, uma senha de app.",
|
||||||
|
"Escolha um provedor ou informe IMAP e SMTP manualmente.",
|
||||||
|
"Dê consentimento, salve e ative Email; depois, envie uma mensagem de teste."
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "Personalizado"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "Consentimento concedido",
|
||||||
|
"help": "Controle de segurança obrigatório. Deixe desativado até decidir conectar esta caixa ao bot.",
|
||||||
|
"choices": {
|
||||||
|
"true": "Concedido",
|
||||||
|
"false": "Não concedido"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "Host IMAP",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "Usuário IMAP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "Senha IMAP",
|
||||||
|
"placeholder": "Senha de app",
|
||||||
|
"help": "Use uma senha de app quando o provedor exigir."
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "Host SMTP",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "Usuário SMTP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "Senha SMTP",
|
||||||
|
"placeholder": "Senha de app",
|
||||||
|
"help": "Normalmente é a mesma senha usada no IMAP."
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "Porta IMAP",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "Porta SMTP",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "Endereço remetente",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "Intervalo de consulta",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Remetentes permitidos",
|
||||||
|
"placeholder": "E-mails separados por vírgulas",
|
||||||
|
"help": "Deixe vazio para exigir pareamento prévio."
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "Verificar DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "Ativado",
|
||||||
|
"false": "Desativado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "Verificar SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "Ativado",
|
||||||
|
"false": "Desativado"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "Cho phép nanobot nhận và trả lời email.",
|
||||||
|
"requirements": "Hộp thư IMAP, gửi SMTP, mật khẩu ứng dụng và sự đồng ý rõ ràng",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "Mở hướng dẫn Email",
|
||||||
|
"officialLabel": "Mở hướng dẫn mật khẩu ứng dụng",
|
||||||
|
"tryIt": "Gửi email thử đến hộp thư đã kết nối.",
|
||||||
|
"summary": "Email đọc thư qua IMAP và trả lời qua SMTP. Dùng hộp thư riêng và cấp quyền trước khi bật.",
|
||||||
|
"steps": [
|
||||||
|
"Tạo hộp thư riêng và mật khẩu ứng dụng nếu cần.",
|
||||||
|
"Chọn nhà cung cấp hoặc nhập thủ công cài đặt IMAP và SMTP.",
|
||||||
|
"Cấp quyền, lưu và bật Email, sau đó gửi tin nhắn thử."
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "Tùy chỉnh"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "Đã đồng ý",
|
||||||
|
"help": "Công tắc an toàn bắt buộc. Chỉ bật sau khi chủ động kết nối hộp thư này với bot.",
|
||||||
|
"choices": {
|
||||||
|
"true": "Đã đồng ý",
|
||||||
|
"false": "Chưa đồng ý"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "Host IMAP",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "Tên người dùng IMAP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "Mật khẩu IMAP",
|
||||||
|
"placeholder": "Mật khẩu ứng dụng",
|
||||||
|
"help": "Dùng mật khẩu ứng dụng khi nhà cung cấp yêu cầu."
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "Host SMTP",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "Tên người dùng SMTP",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "Mật khẩu SMTP",
|
||||||
|
"placeholder": "Mật khẩu ứng dụng",
|
||||||
|
"help": "Thường giống mật khẩu ứng dụng dùng cho IMAP."
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "Cổng IMAP",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "Cổng SMTP",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "Địa chỉ gửi",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "Chu kỳ kiểm tra",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "Người gửi được phép",
|
||||||
|
"placeholder": "Địa chỉ email, phân tách bằng dấu phẩy",
|
||||||
|
"help": "Để trống để yêu cầu ghép nối trước."
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "Xác minh DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "Bật",
|
||||||
|
"false": "Tắt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "Xác minh SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "Bật",
|
||||||
|
"false": "Tắt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "让 nanobot 接收并回复电子邮件。",
|
||||||
|
"requirements": "IMAP 收件箱、SMTP 发件服务、应用专用密码和明确授权",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "打开邮件配置指南",
|
||||||
|
"officialLabel": "打开应用专用密码指南",
|
||||||
|
"tryIt": "向已连接的邮箱发送一封测试邮件。",
|
||||||
|
"summary": "邮件渠道通过 IMAP 读取邮件并通过 SMTP 回复。请使用专用邮箱,并在启用前明确授权。",
|
||||||
|
"steps": [
|
||||||
|
"创建专用邮箱,并在服务商要求时创建应用专用密码。",
|
||||||
|
"选择服务商预设,或手动填写 IMAP 和 SMTP 设置。",
|
||||||
|
"授予授权,保存并启用邮件渠道,然后向邮箱发送一封测试邮件。"
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "自定义"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "已授权",
|
||||||
|
"help": "必需的安全开关。仅在确定要连接此机器人邮箱后才开启。",
|
||||||
|
"choices": {
|
||||||
|
"true": "已授权",
|
||||||
|
"false": "未授权"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "IMAP 主机",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "IMAP 用户名",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "IMAP 密码",
|
||||||
|
"placeholder": "应用专用密码",
|
||||||
|
"help": "如果邮件服务商要求,请使用应用专用密码。"
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "SMTP 主机",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "SMTP 用户名",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "SMTP 密码",
|
||||||
|
"placeholder": "应用专用密码",
|
||||||
|
"help": "通常与 IMAP 使用同一个应用专用密码。"
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "IMAP 端口",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "SMTP 端口",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "发件地址",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "轮询间隔",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "允许的发件人",
|
||||||
|
"placeholder": "邮箱地址,用逗号分隔",
|
||||||
|
"help": "留空则要求发件人先完成配对。"
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "验证 DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "开启",
|
||||||
|
"false": "关闭"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "验证 SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "开启",
|
||||||
|
"false": "关闭"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"description": "讓 nanobot 接收並回覆電子郵件。",
|
||||||
|
"requirements": "IMAP 收件匣、SMTP 寄件服務、應用程式密碼和明確授權",
|
||||||
|
"setup": {
|
||||||
|
"docsLabel": "開啟郵件設定指南",
|
||||||
|
"officialLabel": "開啟應用程式密碼指南",
|
||||||
|
"tryIt": "向已連接的信箱傳送一封測試郵件。",
|
||||||
|
"summary": "郵件渠道透過 IMAP 讀取郵件並透過 SMTP 回覆。請使用專用信箱,並在啟用前明確授權。",
|
||||||
|
"steps": [
|
||||||
|
"建立專用信箱,並在服務商要求時建立應用程式密碼。",
|
||||||
|
"選擇服務商預設,或手動填入 IMAP 和 SMTP 設定。",
|
||||||
|
"授予權限,儲存並啟用郵件渠道,然後向信箱傳送一封測試郵件。"
|
||||||
|
],
|
||||||
|
"presets": {
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"outlook": "Outlook",
|
||||||
|
"icloud": "iCloud",
|
||||||
|
"custom": "自訂"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"consentGranted": {
|
||||||
|
"label": "已授權",
|
||||||
|
"help": "必要的安全開關。僅在確定要連接此機器人信箱後才開啟。",
|
||||||
|
"choices": {
|
||||||
|
"true": "已授權",
|
||||||
|
"false": "未授權"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"imapHost": {
|
||||||
|
"label": "IMAP 主機",
|
||||||
|
"placeholder": "imap.gmail.com"
|
||||||
|
},
|
||||||
|
"imapUsername": {
|
||||||
|
"label": "IMAP 使用者名稱",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"imapPassword": {
|
||||||
|
"label": "IMAP 密碼",
|
||||||
|
"placeholder": "應用程式密碼",
|
||||||
|
"help": "若郵件服務商要求,請使用應用程式密碼。"
|
||||||
|
},
|
||||||
|
"smtpHost": {
|
||||||
|
"label": "SMTP 主機",
|
||||||
|
"placeholder": "smtp.gmail.com"
|
||||||
|
},
|
||||||
|
"smtpUsername": {
|
||||||
|
"label": "SMTP 使用者名稱",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"smtpPassword": {
|
||||||
|
"label": "SMTP 密碼",
|
||||||
|
"placeholder": "應用程式密碼",
|
||||||
|
"help": "通常與 IMAP 使用同一個應用程式密碼。"
|
||||||
|
},
|
||||||
|
"imapPort": {
|
||||||
|
"label": "IMAP 連接埠",
|
||||||
|
"placeholder": "993"
|
||||||
|
},
|
||||||
|
"smtpPort": {
|
||||||
|
"label": "SMTP 連接埠",
|
||||||
|
"placeholder": "587"
|
||||||
|
},
|
||||||
|
"fromAddress": {
|
||||||
|
"label": "寄件地址",
|
||||||
|
"placeholder": "bot@example.com"
|
||||||
|
},
|
||||||
|
"pollIntervalSeconds": {
|
||||||
|
"label": "輪詢間隔",
|
||||||
|
"placeholder": "30"
|
||||||
|
},
|
||||||
|
"allowFrom": {
|
||||||
|
"label": "允許的寄件者",
|
||||||
|
"placeholder": "電子郵件地址,以逗號分隔",
|
||||||
|
"help": "留空則要求寄件者先完成配對。"
|
||||||
|
},
|
||||||
|
"verifyDkim": {
|
||||||
|
"label": "驗證 DKIM",
|
||||||
|
"choices": {
|
||||||
|
"true": "開啟",
|
||||||
|
"false": "關閉"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verifySpf": {
|
||||||
|
"label": "驗證 SPF",
|
||||||
|
"choices": {
|
||||||
|
"true": "開啟",
|
||||||
|
"false": "關閉"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Feishu/Lark channel package."""
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Dependency-free Feishu configuration model shared by management and runtime."""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from nanobot.config.schema import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuConfig(Base):
|
||||||
|
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||||
|
|
||||||
|
instance_id: str = "default"
|
||||||
|
name: str = "nanobot"
|
||||||
|
identity_key: str = ""
|
||||||
|
enabled: bool = False
|
||||||
|
app_id: str = ""
|
||||||
|
app_secret: str = ""
|
||||||
|
encrypt_key: str = ""
|
||||||
|
verification_token: str = ""
|
||||||
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
|
react_emoji: str = "THUMBSUP"
|
||||||
|
done_emoji: str | None = None
|
||||||
|
tool_hint_prefix: str = "\U0001f527"
|
||||||
|
group_policy: Literal["open", "mention"] = "mention"
|
||||||
|
reply_to_message: bool = False
|
||||||
|
streaming: bool = True
|
||||||
|
domain: Literal["feishu", "lark"] = "feishu"
|
||||||
|
topic_isolation: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def feishu_default_config() -> dict[str, object]:
|
||||||
|
return FeishuConfig().model_dump(by_alias=True)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["FeishuConfig", "feishu_default_config"]
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Short-lived WebUI channel connection sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
|
||||||
|
from nanobot.channels.feishu import runtime as feishu
|
||||||
|
from nanobot.channels.feishu.instances import DEFAULT_INSTANCE_ID, validate_instance_id
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FeishuConnectSession:
|
||||||
|
id: str
|
||||||
|
instance_id: str
|
||||||
|
instance_name: str
|
||||||
|
device_code: str
|
||||||
|
qr_url: str
|
||||||
|
domain: str
|
||||||
|
interval: int
|
||||||
|
expire_in: int
|
||||||
|
created_wall: float
|
||||||
|
deadline: float
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeishuConnectStore:
|
||||||
|
"""In-memory Feishu/Lark QR connection state.
|
||||||
|
|
||||||
|
Sessions intentionally live only in the gateway process and expire quickly.
|
||||||
|
The app secret is never returned to the browser; it is saved directly to
|
||||||
|
config when Feishu/Lark completes authorization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._sessions: dict[str, FeishuConnectSession] = {}
|
||||||
|
|
||||||
|
async def handle(self, action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
|
"""Handle one generic settings connection action."""
|
||||||
|
if action == "start":
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self.start,
|
||||||
|
domain=(query_first(query, "domain") or "feishu").strip(),
|
||||||
|
instance_id=(query_first(query, "instance_id") or "default").strip(),
|
||||||
|
mode=(query_first(query, "mode") or "replace").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
session_id = (query_first(query, "session_id") or "").strip()
|
||||||
|
if not session_id:
|
||||||
|
raise ChannelConnectError("missing Feishu connect session")
|
||||||
|
if action == "poll":
|
||||||
|
return await asyncio.to_thread(self.poll, session_id)
|
||||||
|
if action == "cancel":
|
||||||
|
return self.cancel(session_id)
|
||||||
|
raise ChannelConnectError(f"unsupported Feishu connect action: {action}", status=404)
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
domain: str = "feishu",
|
||||||
|
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||||
|
mode: str = "replace",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
domain = _normalize_domain(domain)
|
||||||
|
instance_id = _resolve_instance_id(instance_id, mode)
|
||||||
|
self._cleanup()
|
||||||
|
try:
|
||||||
|
feishu._init_registration(domain)
|
||||||
|
begin = feishu._begin_registration(domain)
|
||||||
|
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
|
||||||
|
raise ChannelConnectError(
|
||||||
|
f"Unable to start Feishu/Lark connection: {exc}",
|
||||||
|
status=502,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
session_id = secrets.token_urlsafe(18)
|
||||||
|
now_wall = time.time()
|
||||||
|
now = time.monotonic()
|
||||||
|
expire_in = int(begin["expire_in"])
|
||||||
|
interval = max(2, int(begin["interval"]))
|
||||||
|
session = FeishuConnectSession(
|
||||||
|
id=session_id,
|
||||||
|
instance_id=instance_id,
|
||||||
|
instance_name=_default_instance_name(instance_id),
|
||||||
|
device_code=str(begin["device_code"]),
|
||||||
|
qr_url=str(begin["qr_url"]),
|
||||||
|
domain=domain,
|
||||||
|
interval=interval,
|
||||||
|
expire_in=expire_in,
|
||||||
|
created_wall=now_wall,
|
||||||
|
deadline=now + expire_in,
|
||||||
|
)
|
||||||
|
self._sessions[session_id] = session
|
||||||
|
return _start_payload(session)
|
||||||
|
|
||||||
|
def poll(self, session_id: str) -> dict[str, Any]:
|
||||||
|
self._cleanup()
|
||||||
|
session = self._sessions.get(session_id)
|
||||||
|
if session is None:
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"status": "expired",
|
||||||
|
"message": "This Feishu connection has expired. Start again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.monotonic() >= session.deadline:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"status": "expired",
|
||||||
|
"message": "This Feishu connection has expired. Start again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = feishu.poll_registration_once(
|
||||||
|
device_code=session.device_code,
|
||||||
|
domain=session.domain,
|
||||||
|
)
|
||||||
|
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
|
||||||
|
session.last_error = str(exc)
|
||||||
|
return _pending_payload(session)
|
||||||
|
|
||||||
|
session.domain = str(result.get("domain") or session.domain)
|
||||||
|
status = result.get("status")
|
||||||
|
if status == "succeeded":
|
||||||
|
session.instance_id = feishu.save_registration_result(
|
||||||
|
result,
|
||||||
|
instance_id=session.instance_id,
|
||||||
|
name=session.instance_name,
|
||||||
|
)
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"instance_id": session.instance_id,
|
||||||
|
"status": "succeeded",
|
||||||
|
"message": "Feishu is connected.",
|
||||||
|
"domain": session.domain,
|
||||||
|
"app_id": result.get("app_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "failed":
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"instance_id": session.instance_id,
|
||||||
|
"status": "failed",
|
||||||
|
"message": "Authorization was cancelled or expired.",
|
||||||
|
"domain": session.domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
return _pending_payload(session)
|
||||||
|
|
||||||
|
def cancel(self, session_id: str) -> dict[str, Any]:
|
||||||
|
session = self._sessions.pop(session_id, None)
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
|
||||||
|
"status": "cancelled",
|
||||||
|
"message": "Feishu connection cancelled.",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
expired = [session_id for session_id, session in self._sessions.items() if now >= session.deadline]
|
||||||
|
for session_id in expired:
|
||||||
|
self._sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_domain(domain: str) -> str:
|
||||||
|
normalized = domain.strip().lower()
|
||||||
|
return normalized if normalized in {"feishu", "lark"} else "feishu"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_instance_id(instance_id: str, mode: str) -> str:
|
||||||
|
if mode == "create":
|
||||||
|
return f"assistant-{secrets.token_hex(3)}"
|
||||||
|
try:
|
||||||
|
return validate_instance_id(instance_id or DEFAULT_INSTANCE_ID)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ChannelConnectError(str(exc), status=400) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _default_instance_name(instance_id: str) -> str:
|
||||||
|
return "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _start_payload(session: FeishuConnectSession) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_id": session.id,
|
||||||
|
"instance_id": session.instance_id,
|
||||||
|
"status": "pending",
|
||||||
|
"qr_url": session.qr_url,
|
||||||
|
"domain": session.domain,
|
||||||
|
"interval_ms": session.interval * 1000,
|
||||||
|
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
|
||||||
|
"message": "Scan with Feishu or Lark to connect.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_payload(session: FeishuConnectSession) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"session_id": session.id,
|
||||||
|
"instance_id": session.instance_id,
|
||||||
|
"status": "pending",
|
||||||
|
"domain": session.domain,
|
||||||
|
"interval_ms": session.interval * 1000,
|
||||||
|
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
|
||||||
|
"message": "Waiting for authorization.",
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user