mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e065cfffe | ||
|
|
27f7549c84 | ||
|
|
f5371a6c5a | ||
|
|
f19efcd990 |
@@ -27,3 +27,9 @@ A bugfix should make the protected invariant clear, change the smallest surface
|
|||||||
## Explicit over magical
|
## Explicit over magical
|
||||||
|
|
||||||
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
||||||
|
|
||||||
|
## Configuration has an explicit owner
|
||||||
|
|
||||||
|
`FileConfigRepository` owns config-file reads, validation, revisions, and atomic writes. Process entry points may use the explicit functions in `config/loader.py`; components with an explicit config path should keep their own repository instance instead of importing a mutable global config object.
|
||||||
|
|
||||||
|
Persisted and runtime views are separate: `load_raw_config()` / `load_raw()` preserve `${VAR}` references for editing, while `load_effective_config()` / `load_effective()` return an isolated snapshot with references resolved. Read-modify-write flows must use `update()` / `update_config()` so a stale object cannot silently overwrite a newer change. Loading config is side-effect free; process-wide policies are applied explicitly during runtime startup.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Config `${VAR}` References
|
## Config `${VAR}` References
|
||||||
|
|
||||||
`config/loader.py` resolves `${VAR}` patterns in `config.json` at load time. This is **not** a shell-like default-value syntax. If the environment variable is missing, `load_config` raises `ValueError` and the agent falls back to default configuration.
|
`load_raw_config()` and `FileConfigRepository.load_raw()` preserve `${VAR}` patterns so Settings can safely edit and save the persisted representation. Runtime entry points use `load_effective_config()` / `load_effective()` to resolve them in an isolated snapshot. This is **not** a shell-like default-value syntax. If a referenced variable is missing, effective loading raises `ValueError`.
|
||||||
|
|
||||||
Example valid usage:
|
Example valid usage:
|
||||||
```json
|
```json
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
|
|||||||
|
|
||||||
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
|
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
|
||||||
|
|
||||||
The only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time.
|
The only escape hatch is `configure_ssrf_whitelist(cidrs)`. Runtime entry points explicitly apply `config.tools.ssrf_whitelist` after loading the effective config; ordinary config reads must not mutate this process-wide policy.
|
||||||
|
|
||||||
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
|
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
|
||||||
|
|
||||||
|
|||||||
@@ -19,25 +19,14 @@ 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:
|
||||||
include:
|
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
|
||||||
- name: minimum, 3.11
|
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
|
||||||
os: ubuntu-latest
|
python-version: ${{ fromJSON('["3.13","3.14"]') }}
|
||||||
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
|
||||||
@@ -57,27 +46,11 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --all-extras --dev
|
run: uv sync --all-extras --dev
|
||||||
|
|
||||||
- name: Install channel dependencies
|
|
||||||
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
|
|
||||||
|
|
||||||
# Channel requirements live in manifests rather than uv.lock. Avoid a
|
|
||||||
# later uv run sync pruning the packages installed by the previous step.
|
|
||||||
- name: Lint with ruff
|
- name: Lint with ruff
|
||||||
if: matrix.coverage
|
run: uv run ruff check nanobot --select F
|
||||||
run: uv run --no-sync ruff check nanobot tests conftest.py
|
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests
|
||||||
if: matrix.coverage
|
run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered
|
||||||
run: >-
|
|
||||||
uv run --no-sync 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 --no-sync python -m pytest
|
|
||||||
--durations=25 --durations-min=1.0
|
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -91,13 +64,9 @@ 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 --frozen-lockfile
|
run: bun install
|
||||||
|
|
||||||
- name: Lint WebUI
|
- name: Lint WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
@@ -110,22 +79,3 @@ jobs:
|
|||||||
- name: Build WebUI
|
- name: Build WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
||||||
docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build image with default channel dependencies
|
|
||||||
run: docker build -t nanobot:test .
|
|
||||||
|
|
||||||
- name: Verify default WhatsApp dependencies
|
|
||||||
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
|
|
||||||
|
|
||||||
- name: Verify runtime dependency permissions
|
|
||||||
run: >-
|
|
||||||
docker run --rm --user 1000:1000 --entrypoint sh nanobot:test -c
|
|
||||||
'test -w /app/.venv && test ! -w /app && test ! -w /app/nanobot &&
|
|
||||||
python -m scripts.install_channel_dependencies discord && python -c "import discord"'
|
|
||||||
|
|||||||
@@ -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 self-contained packages auto-discovered via `pkgutil` scanning.
|
- **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.
|
||||||
- **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`).
|
||||||
|
|||||||
+6
-40
@@ -15,63 +15,29 @@ RUN apt-get update && \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Keep the runtime environment writable by the non-root nanobot user. Enabled
|
|
||||||
# channels may install their manifest-declared dependencies at startup.
|
|
||||||
ENV VIRTUAL_ENV=/app/.venv
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
|
||||||
RUN uv venv --seed "$VIRTUAL_ENV"
|
|
||||||
|
|
||||||
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
# Install Python dependencies first (cached layer). Hatch reads the custom build
|
||||||
# hook from hatch_build.py even for this metadata-only install.
|
# hook from hatch_build.py even for this metadata-only install.
|
||||||
ARG NANOBOT_EXTRAS=
|
ARG NANOBOT_EXTRAS=whatsapp
|
||||||
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
|
||||||
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
RUN mkdir -p nanobot && touch nanobot/__init__.py && \
|
||||||
if [ -n "$NANOBOT_EXTRAS" ]; then \
|
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]" && \
|
||||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
|
|
||||||
--python "$VIRTUAL_ENV/bin/python" --no-cache ".[${NANOBOT_EXTRAS}]"; \
|
|
||||||
else \
|
|
||||||
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
|
|
||||||
--python "$VIRTUAL_ENV/bin/python" --no-cache .; \
|
|
||||||
fi && \
|
|
||||||
rm -rf nanobot
|
rm -rf nanobot
|
||||||
|
|
||||||
# Copy the full source and install
|
# Copy the full source and install
|
||||||
COPY nanobot/ nanobot/
|
COPY nanobot/ nanobot/
|
||||||
COPY scripts/install_channel_dependencies.py scripts/
|
|
||||||
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 --python "$VIRTUAL_ENV/bin/python" --no-cache .
|
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
|
||||||
|
|
||||||
# Preinstall selected channel dependencies from their manifests. A comma-separated
|
# Create non-root user and config directory
|
||||||
# list keeps the image configurable while preserving WhatsApp in the default image.
|
|
||||||
ARG NANOBOT_CHANNELS=whatsapp
|
|
||||||
RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \
|
|
||||||
python -m scripts.install_channel_dependencies "$channel"; \
|
|
||||||
done
|
|
||||||
|
|
||||||
# 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 the non-root user and hand ownership of the writable virtualenv to it.
|
|
||||||
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 && \
|
||||||
chown -R nanobot:nanobot /home/nanobot /app/.venv
|
chown -R nanobot:nanobot /home/nanobot /app
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
# Start as root so the entrypoint can chown the data dir (on Render, the
|
USER nanobot
|
||||||
# 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,15 +46,6 @@
|
|||||||
| 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?
|
||||||
|
|
||||||
@@ -110,13 +101,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. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it.
|
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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -134,7 +125,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 installer also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
|
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**.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -174,24 +165,18 @@ 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 .
|
python -m pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
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**
|
||||||
@@ -261,13 +246,13 @@ For another provider, the same config shape still applies:
|
|||||||
|
|
||||||
**3. Open the WebUI**
|
**3. Open the WebUI**
|
||||||
|
|
||||||
The stable-compatible path is:
|
Start the browser workbench:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
`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`.
|
||||||
|
|
||||||
For manual or terminal-only setup, test one CLI message:
|
For manual or terminal-only setup, test one CLI message:
|
||||||
|
|
||||||
@@ -307,7 +292,7 @@ The WebUI ships **inside the published wheel** — no extra build step. It is th
|
|||||||
nanobot webui
|
nanobot webui
|
||||||
```
|
```
|
||||||
|
|
||||||
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 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).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -381,7 +366,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, review, and contribution gui
|
|||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### 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 after enabling the channels used in production; their manifest-declared dependencies are installed into the same environment
|
- Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
|
||||||
- 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
@@ -1,51 +0,0 @@
|
|||||||
"""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
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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
|
|
||||||
+5
-2
@@ -2,12 +2,15 @@ x-common-config: &common-config
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
|
||||||
NANOBOT_CHANNELS: ${NANOBOT_CHANNELS:-whatsapp}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ~/.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:
|
||||||
|
|||||||
+126
-60
@@ -1,84 +1,150 @@
|
|||||||
# nanobot Documentation
|
# nanobot Docs
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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).
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
| Your situation | Read this | You are done when... |
|
| Goal | Read | Outcome |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 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 |
|
| 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 |
|
||||||
| 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 |
|
| Install and get the first reply | [`quick-start.md`](./quick-start.md) | A working CLI agent and a known-good config path |
|
||||||
| Something already failed | [Troubleshooting](./troubleshooting.md) | You have isolated the problem to install, config, model, gateway, channel, or tool access |
|
| Understand how the pieces fit | [`concepts.md`](./concepts.md) | Mental model for config, workspace, gateway, channels, tools, memory, and sessions |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
The recommended first-run path is:
|
## Task Guides
|
||||||
|
|
||||||
1. Install nanobot.
|
Use these pages when you know the workflow you want and do not want to scan the
|
||||||
2. Choose **Quick Start** in `nanobot onboard --wizard`.
|
full reference first.
|
||||||
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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Learn the browser workbench | [WebUI](./webui.md) |
|
| Build a personal AI agent | [`guides/build-a-personal-ai-agent.md`](./guides/build-a-personal-ai-agent.md) |
|
||||||
| Connect Telegram, Discord, Slack, Feishu, WeChat, Email, or another chat app | [Chat Apps](./chat-apps.md) |
|
| Run a self-hosted AI agent | [`guides/self-hosted-ai-agent.md`](./guides/self-hosted-ai-agent.md) |
|
||||||
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
|
| Use a browser AI agent WebUI | [`guides/ai-agent-webui.md`](./guides/ai-agent-webui.md) |
|
||||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
| Connect an AI agent to chat apps | [`guides/chat-app-ai-agent.md`](./guides/chat-app-ai-agent.md) |
|
||||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
| Run long-running agent tasks | [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) |
|
||||||
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
| Schedule or trigger agent turns | [`automations.md`](./automations.md) |
|
||||||
| Generate images | [Image Generation](./image-generation.md) |
|
| Add long-term agent memory | [`guides/ai-agent-memory.md`](./guides/ai-agent-memory.md) |
|
||||||
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
| Add MCP tools to an agent | [`guides/mcp-tools-for-ai-agents.md`](./guides/mcp-tools-for-ai-agents.md) |
|
||||||
| Understand and manage long-term memory | [Memory](./memory.md) |
|
| Run an agent from Python | [`guides/python-ai-agent-sdk.md`](./guides/python-ai-agent-sdk.md) |
|
||||||
| Run nanobot continuously | [Deployment](./deployment.md) |
|
| Expose an OpenAI-compatible agent API | [`guides/openai-compatible-agent-api.md`](./guides/openai-compatible-agent-api.md) |
|
||||||
| Run separate bots or workspaces | [Multiple Instances](./multiple-instances.md) |
|
| Deploy a long-running agent gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
||||||
| Call nanobot from Python | [Python SDK](./python-sdk.md) |
|
|
||||||
| Expose an OpenAI-compatible endpoint | [OpenAI-Compatible API](./openai-api.md) |
|
|
||||||
|
|
||||||
For shorter, outcome-focused walkthroughs, browse the [task guide index](./guides/README.md).
|
Platform-specific chat guides:
|
||||||
|
[`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).
|
||||||
|
|
||||||
## Operate nanobot
|
Configuration guides:
|
||||||
|
[`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).
|
||||||
|
|
||||||
| Need | Read |
|
## After the First Reply Works
|
||||||
|---|---|
|
|
||||||
| Commands and flags | [CLI Reference](./cli-reference.md) |
|
Do not configure everything at once. Pick one next surface:
|
||||||
| In-chat slash commands | [In-Chat Commands](./chat-commands.md) |
|
|
||||||
| Config, workspace, gateway, sessions, tools, and memory in plain language | [Concepts](./concepts.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`.
|
||||||
| Provider/model matching and selection | [Providers and Models](./providers.md) |
|
|
||||||
| Setup and runtime diagnosis | [Troubleshooting](./troubleshooting.md) |
|
| Next goal | Read | First check |
|
||||||
| 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
|
||||||
|
|
||||||
Use reference pages to look up an exact option after you know what you are trying to configure:
|
| Area | Read | Best for |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
| Area | Reference |
|
## Fast Lookup
|
||||||
|
|
||||||
|
| Need | Jump to |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Every configuration field and default | [Configuration](./configuration.md) |
|
| Provider/model resolution order | [`providers.md#provider-resolution`](./providers.md#provider-resolution) |
|
||||||
| Provider and model behavior | [Providers and Models](./providers.md) |
|
| Model presets and fallback chains | [`providers.md#model-presets`](./providers.md#model-presets) and [`providers.md#fallback-models`](./providers.md#fallback-models) |
|
||||||
| Chat channel prerequisites and manual JSON | [Chat Apps](./chat-apps.md) |
|
| Langfuse environment variables | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) |
|
||||||
| WebSocket authentication and wire protocol | [WebSocket](./websocket.md) |
|
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
|
||||||
| Python SDK classes, events, sessions, and hooks | [Python SDK](./python-sdk.md) |
|
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
|
||||||
| OpenAI-compatible HTTP routes and payloads | [OpenAI-Compatible API](./openai-api.md) |
|
| Python SDK usage | [`python-sdk.md`](./python-sdk.md) |
|
||||||
| Runtime self-inspection and tuning | [My Tool](./my-tool.md) |
|
| Scheduled automations and local triggers | [`automations.md`](./automations.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) |
|
||||||
|
|
||||||
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.
|
## Extend nanobot
|
||||||
|
|
||||||
## Extend or Contribute
|
| Goal | Read | Outcome |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
These pages explain implementation and extension points. You do not need them to install or operate nanobot.
|
## Reading Strategy
|
||||||
|
|
||||||
| Goal | Read |
|
Use the docs in this order when you are unsure where to go:
|
||||||
|---|---|
|
|
||||||
| 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) |
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
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` |
|
||||||
| Channel packages | `nanobot/channels/<channel>/` |
|
| Built-in channels | `nanobot/channels/*.py` |
|
||||||
| Discovery and lifecycle | `nanobot/channels/manager.py` |
|
| Discovery and lifecycle | `nanobot/channels/manager.py` |
|
||||||
| WebSocket/WebUI channel | `nanobot/channels/websocket/` |
|
| WebSocket/WebUI channel | `nanobot/channels/websocket.py` |
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
## 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 | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
|
| Channel | Implement `BaseChannel`, expose an entry point, follow [`channel-plugin-guide.md`](./channel-plugin-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/` |
|
||||||
|
|||||||
@@ -1,793 +0,0 @@
|
|||||||
# 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, dependency requirements, 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 plugins enable webhook
|
|
||||||
nanobot gateway # test end-to-end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ nanobot plugins list
|
|
||||||
|
|
||||||
Name Type Enabled
|
|
||||||
discord channel no
|
|
||||||
telegram channel yes
|
|
||||||
webhook channel yes
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
+10
-28
@@ -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 Package Guide](./channel-package-guide.md).
|
Want to build your own channel? See the [Channel Plugin Guide](./channel-plugin-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,28 +26,16 @@ 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.
|
||||||
|
|
||||||
## Recommended Setup in the WebUI
|
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a
|
||||||
|
snippet includes `allowFrom`, it is showing a static allowlist. For
|
||||||
For normal local setup, let the WebUI write and validate the channel config:
|
pairing-based access on supported channels, omit `allowFrom`; Slack and
|
||||||
|
Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing
|
||||||
1. Run `nanobot webui`.
|
codes.
|
||||||
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,
|
||||||
> enable the channel in the same Python environment so nanobot installs its
|
> install the channel extra in the same Python environment before enabling or
|
||||||
> manifest-declared dependencies:
|
> restarting that channel:
|
||||||
>
|
>
|
||||||
> ```bash
|
> ```bash
|
||||||
> nanobot plugins enable <channel>
|
> nanobot plugins enable <channel>
|
||||||
@@ -59,9 +47,7 @@ The sections below explain what each chat platform requires and provide manual c
|
|||||||
> 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.
|
||||||
|
|
||||||
## Manual Setup Pattern
|
## Common 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:
|
||||||
|
|
||||||
@@ -185,7 +171,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
|
|||||||
nanobot plugins enable mochat
|
nanobot plugins enable mochat
|
||||||
```
|
```
|
||||||
|
|
||||||
Without these dependencies, Mochat still works through HTTP polling.
|
Without this extra, Mochat still works through HTTP polling.
|
||||||
|
|
||||||
**1. Ask nanobot to set up Mochat for you**
|
**1. Ask nanobot to set up Mochat for you**
|
||||||
|
|
||||||
@@ -393,10 +379,6 @@ 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 webui` | Prepare the local WebUI, start the gateway, and open the browser workbench |
|
| WebUI | `nanobot gateway` plus WebSocket channel | Browser workbench served by the WebSocket channel on port `8765` |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Provider and Model Selection
|
## Provider and Model Selection
|
||||||
|
|
||||||
|
|||||||
+45
-13
@@ -4,8 +4,6 @@ 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.
|
||||||
@@ -13,7 +11,47 @@ 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, run `nanobot onboard --refresh`. nanobot adds missing default fields while preserving your existing values.
|
> 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.
|
||||||
|
|
||||||
|
## Python Configuration API
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> `nanobot.config.load_config` and `nanobot.config.loader.load_config` have been
|
||||||
|
> removed. There is intentionally no compatibility alias because the old name
|
||||||
|
> did not distinguish the persisted representation from the runtime view.
|
||||||
|
|
||||||
|
Python embedders and plugins must choose the view they need explicitly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.config import (
|
||||||
|
apply_config_runtime_policies,
|
||||||
|
load_effective_config,
|
||||||
|
load_raw_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
path = Path.home() / ".nanobot" / "config.json"
|
||||||
|
|
||||||
|
# For settings editors and persistence flows: preserves ${VAR} placeholders.
|
||||||
|
persisted = load_raw_config(path)
|
||||||
|
|
||||||
|
# For agents, providers, channels, and tools: resolves ${VAR} placeholders.
|
||||||
|
runtime = load_effective_config(path)
|
||||||
|
apply_config_runtime_policies(runtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this migration mapping:
|
||||||
|
|
||||||
|
| Previous call | Replacement |
|
||||||
|
|---|---|
|
||||||
|
| `load_config(path)` used to inspect or edit persisted values | `load_raw_config(path)` |
|
||||||
|
| `resolve_config_env_vars(load_config(path))` used at runtime | `load_effective_config(path)` |
|
||||||
|
| Loading followed by implicit process-policy setup | `load_effective_config(path)`, then `apply_config_runtime_policies(config)` at the runtime boundary |
|
||||||
|
|
||||||
|
This is a Python API breaking change for embedders and plugins that import the
|
||||||
|
old function. The `config.json` format is unchanged, and the bundled CLI,
|
||||||
|
gateway, and WebUI already use the explicit APIs.
|
||||||
|
|
||||||
## Configuration Guides
|
## Configuration Guides
|
||||||
|
|
||||||
@@ -49,9 +87,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 a Setting Lives
|
## Where to Edit First
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
| Task | First keys to check | Verify with | Deep dive |
|
| Task | First keys to check | Verify with | Deep dive |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -187,7 +225,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. 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_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_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. |
|
||||||
@@ -204,8 +242,6 @@ These variables are process-level switches. Set them in the same terminal, servi
|
|||||||
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
|
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip `nanobot onboard --wizard` after one-command install. |
|
||||||
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
|
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
|
||||||
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
|
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
|
||||||
| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. |
|
|
||||||
| `NANOBOT_CHANNELS` | `whatsapp` | Docker build argument containing comma-separated channels whose manifest dependencies are preinstalled. |
|
|
||||||
| `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. |
|
| `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. |
|
||||||
|
|
||||||
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
|
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
|
||||||
@@ -1921,7 +1957,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. 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.
|
**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).
|
||||||
|
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
@@ -2033,10 +2069,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+6
-47
@@ -62,22 +62,6 @@ Restart the deployed process after editing `config.json`. Long-running processes
|
|||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
The default image preinstalls WhatsApp dependencies. To bake other enabled
|
|
||||||
channels into an image (recommended for deployments without PyPI access), pass
|
|
||||||
a comma-separated `NANOBOT_CHANNELS` build argument:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NANOBOT_CHANNELS=telegram,slack docker compose build
|
|
||||||
```
|
|
||||||
|
|
||||||
The image keeps nanobot in a virtual environment owned by its built-in non-root
|
|
||||||
runtime user (UID 1000). If an enabled channel was not preinstalled, gateway
|
|
||||||
startup can therefore install its manifest-declared dependencies. Rebuilding
|
|
||||||
with `NANOBOT_CHANNELS` keeps that installation reproducible instead of relying
|
|
||||||
on the container's writable layer. If you override the container with a
|
|
||||||
different `--user`, bake every enabled channel into the image because that UID
|
|
||||||
is not guaranteed write access to the virtual environment.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm nanobot-cli onboard # first-time setup
|
docker compose run --rm nanobot-cli onboard # first-time setup
|
||||||
vim ~/.nanobot/config.json # add API keys
|
vim ~/.nanobot/config.json # add API keys
|
||||||
@@ -90,32 +74,12 @@ 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
|
||||||
# Build the image
|
# Build the image
|
||||||
docker build -t nanobot .
|
docker build -t nanobot .
|
||||||
|
|
||||||
# Or preinstall a regular Python extra such as Bedrock support
|
|
||||||
docker build --build-arg NANOBOT_EXTRAS=bedrock -t nanobot .
|
|
||||||
|
|
||||||
# Or preinstall dependencies for a specific set of channels
|
|
||||||
docker build --build-arg NANOBOT_CHANNELS=telegram,slack -t nanobot .
|
|
||||||
|
|
||||||
# Initialize config (first time only)
|
# Initialize config (first time only)
|
||||||
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
|
||||||
|
|
||||||
@@ -123,17 +87,12 @@ 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).
|
||||||
# `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway
|
# Mirrors the security caps and port mappings declared in docker-compose.yml:
|
||||||
# health endpoint on 18790.
|
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
|
||||||
docker run \
|
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
|
||||||
--cap-drop ALL \
|
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
|
||||||
-v ~/.nanobot:/home/nanobot/.nanobot \
|
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
|
||||||
-p 18790:18790 -p 8765:8765 \
|
# endpoint on 18790.
|
||||||
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 \
|
||||||
|
|||||||
+11
-16
@@ -1,20 +1,21 @@
|
|||||||
# nanobot Task Guides
|
# nanobot Guides
|
||||||
|
|
||||||
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.
|
These guides are short task entry points. Use them when you know what you want
|
||||||
|
to build, then follow the linked reference docs for complete option tables and
|
||||||
|
edge cases.
|
||||||
|
|
||||||
## Start and Use
|
## Build and operate
|
||||||
|
|
||||||
| 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) |
|
||||||
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
|
|
||||||
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
|
||||||
| Run a sustained goal | [Long-running AI agent](./long-running-ai-agent.md) |
|
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
|
||||||
| Add long-term memory | [AI agent memory](./ai-agent-memory.md) |
|
| Run long-running tasks | [Long-running AI agent](./long-running-ai-agent.md) |
|
||||||
|
| Add memory | [AI agent memory](./ai-agent-memory.md) |
|
||||||
|
| Deploy a gateway | [Deploy a long-running nanobot AI agent gateway](./deploy-nanobot-gateway.md) |
|
||||||
|
|
||||||
## Connect a Chat App
|
## Connect and integrate
|
||||||
|
|
||||||
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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -28,15 +29,10 @@ Use **Settings → Channels** in the WebUI for guided setup. These guides explai
|
|||||||
| 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 and Operate
|
## Configure
|
||||||
|
|
||||||
| Goal | Guide |
|
| Goal | Guide |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -44,7 +40,6 @@ Use **Settings → Channels** in the WebUI for guided setup. These guides explai
|
|||||||
| Enable web search | [Configure web search](./configure-web-search.md) |
|
| Enable web search | [Configure web search](./configure-web-search.md) |
|
||||||
| Add model fallback | [Configure model fallback](./configure-model-fallback.md) |
|
| Add model fallback | [Configure model fallback](./configure-model-fallback.md) |
|
||||||
| Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) |
|
| Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) |
|
||||||
| Improve Ollama tool prompt-cache reuse | [Configure Ollama prompt caching](./configure-ollama-prompt-cache.md) |
|
|
||||||
| Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) |
|
| Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) |
|
||||||
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
|
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
|
||||||
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |
|
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |
|
||||||
|
|||||||
@@ -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 webui
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
Send `Hello!` in the WebUI before adding a channel. Then choose one platform guide for the bot/account prerequisites:
|
Then choose one platform guide:
|
||||||
|
|
||||||
- [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,31 +38,28 @@ Send `Hello!` in the WebUI before adding a channel. Then choose one platform gui
|
|||||||
|
|
||||||
## Minimal working example
|
## Minimal working example
|
||||||
|
|
||||||
Use the guided channel setup:
|
Every channel follows the same pattern:
|
||||||
|
|
||||||
1. Get the platform token, login state, webhook, or mailbox credentials.
|
1. Get the platform token, login state, webhook, or mailbox credentials.
|
||||||
2. Open **Settings → Channels** in the WebUI.
|
2. Merge the channel snippet into `~/.nanobot/config.json`.
|
||||||
3. Choose the platform and open its setup panel.
|
3. Prefer pairing for DM-capable channels: omit `allowFrom`, then approve the
|
||||||
4. Complete the credential or QR flow and install optional support if prompted.
|
first DM's pairing code.
|
||||||
5. Restart when the WebUI requests it.
|
4. For channels without pairing, such as Email, keep access narrow with
|
||||||
6. Send a private test message.
|
`allowFrom` or platform-specific allow lists.
|
||||||
7. Approve the pairing request in the WebUI when a DM-capable channel asks for one.
|
5. Check status:
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
The `nanobot webui` command already runs the gateway. For a chat-only or server deployment, start it directly:
|
6. Start the gateway:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
Use the full [Chat Apps reference](../chat-apps.md) when you manage `config.json` directly or need platform-specific advanced settings.
|
7. Send a test DM, approve the pairing code when prompted, then send the test
|
||||||
|
message again.
|
||||||
|
|
||||||
## Production notes
|
## Production notes
|
||||||
|
|
||||||
@@ -83,7 +80,8 @@ Use the full [Chat Apps reference](../chat-apps.md) when you manage `config.json
|
|||||||
|
|
||||||
- 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 the pending request in the WebUI or use `/pairing approve <code>` from an authorized chat.
|
- If the first DM returns a pairing code, approve it with
|
||||||
|
`/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 integration configured through Apps or `~/.nanobot/config.json`
|
- one MCP server entry in `~/.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,15 +27,7 @@ remote HTTP endpoint.
|
|||||||
|
|
||||||
## Minimal working example
|
## Minimal working example
|
||||||
|
|
||||||
For local interactive setup:
|
Add this to `~/.nanobot/config.json`:
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
# How to Improve Ollama Tool-Calling Prompt Cache Reuse in nanobot
|
|
||||||
|
|
||||||
Some Ollama model templates move or remove tool definitions as a conversation
|
|
||||||
switches between user, assistant, and tool messages. nanobot can send a correct
|
|
||||||
append-only chat request while the model template still renders a different token
|
|
||||||
prefix. On slower local hardware, re-evaluating that prefix can add tens of seconds
|
|
||||||
to an otherwise simple tool-using turn.
|
|
||||||
|
|
||||||
This guide shows how to diagnose that specific pattern and create a derived
|
|
||||||
`llama3.1:8b` tag with a prefix-stable tool template. It does not modify nanobot or
|
|
||||||
overwrite the original Ollama model.
|
|
||||||
|
|
||||||
## What you will build
|
|
||||||
|
|
||||||
- a repeatable two-turn cache check
|
|
||||||
- an optional derived `llama3.1:8b-prefix-stable-v1` Ollama tag
|
|
||||||
- a nanobot model preset that uses the derived tag
|
|
||||||
|
|
||||||
## When to use this
|
|
||||||
|
|
||||||
Use this guide when all of the following are true:
|
|
||||||
|
|
||||||
- direct Ollama responses are reasonably fast;
|
|
||||||
- nanobot becomes slow after the model calls a tool;
|
|
||||||
- Ollama logs show a long main prompt, a much shorter tool follow-up, and low
|
|
||||||
initial cache reuse on the next main prompt;
|
|
||||||
- the model is `llama3.1:8b` with a template that renders concrete tools only for
|
|
||||||
the final user message.
|
|
||||||
|
|
||||||
Do not apply this template to another model family without checking that model's
|
|
||||||
tool-call format first.
|
|
||||||
|
|
||||||
## Diagnose the rendered prompt
|
|
||||||
|
|
||||||
Stop any existing Ollama process, then start a single-slot debug server. A single
|
|
||||||
slot makes the cache sequence easier to read.
|
|
||||||
|
|
||||||
**macOS or Linux**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OLLAMA_CONTEXT_LENGTH=16384 \
|
|
||||||
OLLAMA_NUM_PARALLEL=1 \
|
|
||||||
OLLAMA_DEBUG=1 \
|
|
||||||
ollama serve
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows PowerShell**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:OLLAMA_CONTEXT_LENGTH = "16384"
|
|
||||||
$env:OLLAMA_NUM_PARALLEL = "1"
|
|
||||||
$env:OLLAMA_DEBUG = "1"
|
|
||||||
ollama serve
|
|
||||||
```
|
|
||||||
|
|
||||||
In another terminal, use a fresh session and explicitly request a tool so both
|
|
||||||
turns exercise the agent loop:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot agent --session cli:ollama-cache-check \
|
|
||||||
--message "Use the exec tool to calculate 2+2, then answer"
|
|
||||||
nanobot agent --session cli:ollama-cache-check \
|
|
||||||
--message "Use the exec tool to calculate 4+7, then answer"
|
|
||||||
```
|
|
||||||
|
|
||||||
In the Ollama output, find each `new prompt` line and the first
|
|
||||||
`cached n_tokens` line that follows it. Later increasing `cached n_tokens` lines
|
|
||||||
are prompt-evaluation progress, not additional initial cache hits.
|
|
||||||
|
|
||||||
A cache-unfriendly tool template may produce a pattern like this:
|
|
||||||
|
|
||||||
```text
|
|
||||||
turn 1 main: 2 / 8460 initially cached
|
|
||||||
turn 1 tool follow-up: 3713 / 3758 initially cached
|
|
||||||
turn 2 main: 3767 / 8519 initially cached
|
|
||||||
```
|
|
||||||
|
|
||||||
The cache is working, but the next main request can reuse only the shorter prompt.
|
|
||||||
Hardware throughput determines how expensive the remaining evaluation is.
|
|
||||||
|
|
||||||
To inspect the API request bodies as well, add
|
|
||||||
`OLLAMA_DEBUG_LOG_REQUESTS=1` before starting Ollama. These logs can contain system
|
|
||||||
prompts, workspace context, and user messages. Keep them local and disable request
|
|
||||||
logging after diagnosis.
|
|
||||||
|
|
||||||
## Why this happens with the stock template
|
|
||||||
|
|
||||||
The tested `llama3.1:8b` template conditionally expands the tool definitions inside
|
|
||||||
a user message:
|
|
||||||
|
|
||||||
```gotemplate
|
|
||||||
{{- if and $.Tools $last }}
|
|
||||||
... render tool definitions ...
|
|
||||||
{{- end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
The first request ends with a user message, so the tools are rendered there. After
|
|
||||||
nanobot appends an assistant tool call and its result, that user message is no
|
|
||||||
longer last, so the same API request history renders without the concrete tool
|
|
||||||
block. On the next user turn, the tools reappear at a new position.
|
|
||||||
|
|
||||||
This is a model-template behavior. At the API boundary, nanobot continues to append
|
|
||||||
the assistant tool call and tool result and sends the same tool definitions.
|
|
||||||
|
|
||||||
## Create a prefix-stable derived model
|
|
||||||
|
|
||||||
Create `PrefixStable.Modelfile` with the content below. The template keeps concrete
|
|
||||||
tool definitions in the system block, where they remain in the same position across
|
|
||||||
user and tool messages.
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM llama3.1:8b
|
|
||||||
|
|
||||||
TEMPLATE """{{- if or .System .Tools }}<|start_header_id|>system<|end_header_id|>
|
|
||||||
{{- if .System }}
|
|
||||||
|
|
||||||
{{ .System }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Tools }}
|
|
||||||
|
|
||||||
Cutting Knowledge Date: December 2023
|
|
||||||
|
|
||||||
When you receive a tool call response, use the output to format an answer to the original user question.
|
|
||||||
|
|
||||||
You are a helpful assistant with tool calling capabilities.
|
|
||||||
|
|
||||||
Given the following functions, respond with a JSON function call with the proper arguments when a tool is needed.
|
|
||||||
|
|
||||||
Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. Do not use variables.
|
|
||||||
|
|
||||||
{{ range .Tools }}
|
|
||||||
{{- . }}
|
|
||||||
{{ end }}
|
|
||||||
{{- end }}<|eot_id|>
|
|
||||||
{{- end }}
|
|
||||||
{{- range $i, $_ := .Messages }}
|
|
||||||
{{- $last := eq (len (slice $.Messages $i)) 1 }}
|
|
||||||
{{- if eq .Role "user" }}<|start_header_id|>user<|end_header_id|>
|
|
||||||
|
|
||||||
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
|
|
||||||
|
|
||||||
{{ end }}
|
|
||||||
{{- else if eq .Role "assistant" }}<|start_header_id|>assistant<|end_header_id|>
|
|
||||||
{{- if .ToolCalls }}
|
|
||||||
{{ range .ToolCalls }}
|
|
||||||
{"name": "{{ .Function.Name }}", "parameters": {{ .Function.Arguments }}}{{ end }}
|
|
||||||
{{- else }}
|
|
||||||
|
|
||||||
{{ .Content }}
|
|
||||||
{{- end }}{{ if not $last }}<|eot_id|>{{ end }}
|
|
||||||
{{- else if eq .Role "tool" }}<|start_header_id|>ipython<|end_header_id|>
|
|
||||||
|
|
||||||
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
|
|
||||||
|
|
||||||
{{ end }}
|
|
||||||
{{- end }}
|
|
||||||
{{- end }}"""
|
|
||||||
```
|
|
||||||
|
|
||||||
Create the new tag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ollama create llama3.1:8b-prefix-stable-v1 -f PrefixStable.Modelfile
|
|
||||||
ollama list
|
|
||||||
```
|
|
||||||
|
|
||||||
Ollama reuses the existing model layers. The new tag adds a small template and
|
|
||||||
manifest instead of copying the base weights.
|
|
||||||
|
|
||||||
## Select the derived model in nanobot
|
|
||||||
|
|
||||||
Merge this preset into `~/.nanobot/config.json` and select it:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"ollama": {
|
|
||||||
"apiBase": "http://localhost:11434/v1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"modelPresets": {
|
|
||||||
"ollamaPrefixStable": {
|
|
||||||
"label": "Ollama Llama 3.1 prefix-stable",
|
|
||||||
"provider": "ollama",
|
|
||||||
"model": "llama3.1:8b-prefix-stable-v1",
|
|
||||||
"maxTokens": 2048,
|
|
||||||
"contextWindowTokens": 16384,
|
|
||||||
"temperature": 0.1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"modelPreset": "ollamaPrefixStable"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify the selected model and repeat the two-turn check:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot status
|
|
||||||
nanobot agent --session cli:ollama-stable-check \
|
|
||||||
--message "Use the exec tool to calculate 2+2, then answer"
|
|
||||||
nanobot agent --session cli:ollama-stable-check \
|
|
||||||
--message "Use the exec tool to calculate 4+7, then answer"
|
|
||||||
```
|
|
||||||
|
|
||||||
In one controlled test with Ollama 0.32.1, `llama3.1:8b`, and one slot, the second
|
|
||||||
main request improved from `3767 / 8519` initially cached (44.22%) to
|
|
||||||
`8505 / 8520` (99.82%). The number of re-evaluated tokens fell from 4752 to 15.
|
|
||||||
Treat these numbers as a diagnostic example, not a performance guarantee.
|
|
||||||
|
|
||||||
## Roll back
|
|
||||||
|
|
||||||
Switch `agents.defaults.modelPreset` back to the original preset. When no config
|
|
||||||
uses the derived tag, remove it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ollama rm llama3.1:8b-prefix-stable-v1
|
|
||||||
```
|
|
||||||
|
|
||||||
Removing the derived tag does not remove `llama3.1:8b`.
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
- The template above is specific to the tested `llama3.1:8b` tool-call format.
|
|
||||||
- Ollama or the model publisher may update the stock template in a later release.
|
|
||||||
- Validate multiple tool calls, tool errors, parallel calls, and long conversations
|
|
||||||
before using a custom template for unattended workloads.
|
|
||||||
- A higher cache ratio reduces prompt evaluation, but model generation, tool
|
|
||||||
execution, process startup, and storage can still dominate end-to-end latency.
|
|
||||||
- Multiple Ollama slots change cache scheduling and may produce different results.
|
|
||||||
|
|
||||||
## Related nanobot docs
|
|
||||||
|
|
||||||
- [Provider Cookbook: Ollama Local Model](../provider-cookbook.md#recipe-ollama-local-model)
|
|
||||||
- [Providers and Models: Ollama](../providers.md#ollama)
|
|
||||||
- [Troubleshooting](../troubleshooting.md)
|
|
||||||
@@ -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 the WebUI or `config.json`
|
- one search provider selected in `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,15 +28,7 @@ provider, API key, proxy, fetch behavior, or SSRF allowlist.
|
|||||||
|
|
||||||
## Minimal working example
|
## Minimal working example
|
||||||
|
|
||||||
For local interactive setup:
|
Use the default search provider:
|
||||||
|
|
||||||
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,19 +2,10 @@
|
|||||||
|
|
||||||
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. 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.
|
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -431,13 +431,7 @@ curl -sS http://localhost:11434/v1/models
|
|||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If every response is slow, try a smaller local model or lower `contextWindowTokens`.
|
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If the response is very slow, try a smaller local model or lower `contextWindowTokens`.
|
||||||
|
|
||||||
If direct Ollama responses are fast but tool-using nanobot turns repeatedly evaluate
|
|
||||||
thousands of prompt tokens, the model's chat template may be moving its tool
|
|
||||||
definitions between requests. See
|
|
||||||
[Improve Ollama Tool-Calling Prompt Cache Reuse](./guides/configure-ollama-prompt-cache.md)
|
|
||||||
for a diagnostic procedure and an optional model-specific workaround.
|
|
||||||
|
|
||||||
## Recipe: vLLM or LM Studio
|
## Recipe: vLLM or LM Studio
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
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?
|
||||||
@@ -331,13 +329,6 @@ Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
|
|||||||
|
|
||||||
Most Ollama setups do not require an API key.
|
Most Ollama setups do not require an API key.
|
||||||
|
|
||||||
Ollama renders the OpenAI-compatible messages and tools through each model's chat
|
|
||||||
template. If ordinary model responses are fast but tool-using turns show low prompt
|
|
||||||
cache reuse, diagnose the rendered template before changing nanobot's context or
|
|
||||||
memory settings. The
|
|
||||||
[Ollama prompt-cache guide](./guides/configure-ollama-prompt-cache.md) explains the
|
|
||||||
log pattern and a tested `llama3.1:8b` workaround.
|
|
||||||
|
|
||||||
### vLLM or Other Local OpenAI-Compatible Server
|
### vLLM or Other Local OpenAI-Compatible Server
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+253
-153
@@ -1,197 +1,153 @@
|
|||||||
# Install and Quick Start
|
# Install and Quick Start
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
If terminals, Python, or API keys are unfamiliar, use the [beginner walkthrough](./start-without-technical-background.md), which explains each term and screen.
|
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.
|
||||||
|
|
||||||
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.
|
## Before You Start
|
||||||
|
|
||||||
## What You Need
|
You need:
|
||||||
|
|
||||||
- Python 3.11 or newer.
|
- Python 3.11 or newer.
|
||||||
- Access to one supported AI provider, company endpoint, or local model server.
|
- 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.
|
||||||
- The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key.
|
- Git only if you install from source.
|
||||||
|
- Node.js or Bun only if you are developing the WebUI itself.
|
||||||
|
|
||||||
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.
|
> [!IMPORTANT]
|
||||||
|
> 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 nanobot
|
## 1. Install
|
||||||
|
|
||||||
The recommended installer keeps nanobot out of the system Python environment and opens the setup wizard when installation finishes.
|
Pick one install method.
|
||||||
|
|
||||||
**macOS / Linux**
|
**One-command setup:**
|
||||||
|
|
||||||
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows PowerShell**
|
On 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 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.
|
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).
|
||||||
|
|
||||||
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
|
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
|
||||||
|
|
||||||
## 2. Complete Quick Start
|
|
||||||
|
|
||||||
The installer opens `nanobot onboard --wizard`. Choose **Quick Start** and follow the prompts:
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Quick Start creates or updates:
|
|
||||||
|
|
||||||
| Path | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
|
|
||||||
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
|
|
||||||
|
|
||||||
If the installer did not open the wizard, run it yourself:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot onboard --wizard
|
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
```powershell
|
||||||
|
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Check the Setup
|
To install the current `main` branch instead, pass `--dev`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot status
|
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
|
||||||
```
|
```
|
||||||
|
|
||||||
You want:
|
```powershell
|
||||||
|
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
|
||||||
- a check mark for **Config** and **Workspace**;
|
|
||||||
- the model or preset you selected;
|
|
||||||
- a configured state for the provider used by that model.
|
|
||||||
|
|
||||||
Most other providers can say `not set`. This command validates local setup but does not call the model.
|
|
||||||
|
|
||||||
## 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.
|
If `curl` or `irm` is unavailable, or GitHub raw downloads are blocked on your network, use one of the manual install methods below.
|
||||||
|
|
||||||
Send:
|
If you prefer to inspect the script first, open [`../scripts/install.sh`](../scripts/install.sh) or [`../scripts/install.ps1`](../scripts/install.ps1).
|
||||||
|
|
||||||
```text
|
**Stable release with `uv`:**
|
||||||
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
|
```bash
|
||||||
uv tool install nanobot-ai
|
uv tool install nanobot-ai
|
||||||
nanobot onboard --wizard
|
nanobot --version
|
||||||
```
|
```
|
||||||
|
|
||||||
**pip in a virtual environment**
|
**Stable release with pip:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install nanobot-ai
|
python -m pip install nanobot-ai
|
||||||
nanobot onboard --wizard
|
nanobot --version
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**Current source**
|
**Latest source checkout:**
|
||||||
|
|
||||||
`bun` or `npm` must be available. Activate a virtual environment first, then run:
|
|
||||||
|
|
||||||
```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 .
|
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
|
||||||
nanobot onboard --wizard
|
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.
|
Initialization creates:
|
||||||
|
|
||||||
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).
|
| Path | What it is |
|
||||||
|
|------|------------|
|
||||||
|
| `~/.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 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:
|
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
|
## 3. Configure a Provider
|
||||||
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.
|
Skip this section if you already configured provider and model settings in the wizard.
|
||||||
|
|
||||||
## Manual Configuration Fallback
|
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.
|
||||||
|
|
||||||
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`.
|
**API key:**
|
||||||
|
|
||||||
A generic OpenAI-compatible setup has this shape:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
"custom": {
|
"custom": {
|
||||||
"apiKey": "${PROVIDER_API_KEY}",
|
"apiKey": "your-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": {
|
||||||
@@ -202,48 +158,192 @@ A generic OpenAI-compatible setup has this shape:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
The provider and model inside a preset must match. The snippet above is only an example. For another provider, replace these values together:
|
||||||
|
|
||||||
## Updating
|
| 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` |
|
||||||
|
|
||||||
Upgrade with the same method you used to install:
|
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).
|
||||||
|
|
||||||
```bash
|
**What about `apiBase` / base URL?**
|
||||||
# Recommended installer
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
|
|
||||||
|
|
||||||
# Or one of these
|
`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:
|
||||||
uv tool upgrade nanobot-ai
|
|
||||||
pipx upgrade nanobot-ai
|
- `custom` for a third-party or self-hosted OpenAI-compatible API;
|
||||||
python -m pip install -U nanobot-ai
|
- 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For a source checkout:
|
```json
|
||||||
|
{
|
||||||
```bash
|
"providers": {
|
||||||
git pull
|
"ollama": {
|
||||||
python -m pip install .
|
"apiBase": "http://localhost:11434/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings.
|
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 the First Reply Fails
|
If you prefer not to store secrets in `config.json`, reference an environment variable and set it before starting nanobot:
|
||||||
|
|
||||||
Do not change several settings at once. Start with:
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"custom": {
|
||||||
|
"apiKey": "${PROVIDER_API_KEY}",
|
||||||
|
"apiBase": "https://api.example.com/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Check the Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot --version
|
|
||||||
nanobot status
|
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!"
|
nanobot agent -m "Hello!"
|
||||||
```
|
```
|
||||||
|
|
||||||
| Symptom | First check |
|
A successful first run proves that:
|
||||||
|---|---|
|
|
||||||
| `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` |
|
|
||||||
|
|
||||||
Continue with the ordered [Troubleshooting guide](./troubleshooting.md) if the cause is still unclear.
|
- 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
|
||||||
|
|
||||||
|
**pip:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install -U nanobot-ai
|
||||||
|
nanobot --version
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**uv:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv tool upgrade nanobot-ai
|
||||||
|
nanobot --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**pipx:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipx upgrade nanobot-ai
|
||||||
|
nanobot --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Source checkout:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
python -m pip install -e .
|
||||||
|
nanobot --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot plugins enable whatsapp
|
||||||
|
```
|
||||||
|
|
||||||
|
## First-Run Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | What to check |
|
||||||
|
|---------|---------------|
|
||||||
|
| `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,62 +1,76 @@
|
|||||||
# Start Without Technical Background
|
# Start Without Technical Background
|
||||||
|
|
||||||
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.
|
This page is for you if you have never used a terminal, edited a JSON file, or configured an AI model before.
|
||||||
|
|
||||||
## What You Will Need
|
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.
|
||||||
|
|
||||||
- A Windows, macOS, or Linux computer.
|
## What You Are Setting Up
|
||||||
- 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.
|
|
||||||
|
|
||||||
An API key is password-like. Do not post it in an issue, screenshot, chat, or public config file.
|
You only need these words for Quick Start:
|
||||||
|
|
||||||
## A Few Useful Words
|
| Word | Plain meaning |
|
||||||
|
|
||||||
| Word | Meaning |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Terminal | A text window where you paste a command and press Enter |
|
| Terminal | A text window where you paste commands and press Enter. |
|
||||||
| Command | One instruction typed into the terminal |
|
| Command | One line of text you run in the terminal. |
|
||||||
| Provider | The service or local server that runs the AI model |
|
| API key | A password-like token from an AI provider. Do not share it publicly. |
|
||||||
| Model ID | The exact model name expected by that provider |
|
| Config file | The settings file nanobot reads when it starts. |
|
||||||
| API key | A secret credential that lets software call the provider |
|
| Wizard | An interactive terminal menu that edits the config file for you. |
|
||||||
| Wizard | A question-and-answer setup menu |
|
| Browser UI | The local web page where you chat with nanobot. |
|
||||||
| WebUI | The local browser page where you use nanobot |
|
|
||||||
|
|
||||||
## 1. Install Python
|
## 1. Open a Terminal
|
||||||
|
|
||||||
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.
|
You will paste commands into a terminal. Copy only the command text inside each code block; do not copy the ``` marks.
|
||||||
|
|
||||||
Open a terminal:
|
| System | How to open it |
|
||||||
|
|
||||||
| System | How |
|
|
||||||
|---|---|
|
|---|---|
|
||||||
| Windows | Press `Win`, type `PowerShell`, and open Windows PowerShell |
|
| Windows | Press `Win`, type `PowerShell`, then open **Windows PowerShell**. |
|
||||||
| macOS | Press `Command+Space`, type `Terminal`, and press Enter |
|
| macOS | Press `Command` + `Space`, type `Terminal`, then press `Enter`. |
|
||||||
| Linux | Open your application menu and search for Terminal |
|
| Linux | Open your app launcher, search for `Terminal`, then open it. |
|
||||||
|
|
||||||
Check Python:
|
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.
|
||||||
|
|
||||||
|
## 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
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
If Windows says `python` is not found, close and reopen PowerShell. If it still does not work, try:
|
||||||
|
|
||||||
## 2. Prepare Your Model Details
|
```bash
|
||||||
|
py --version
|
||||||
|
```
|
||||||
|
|
||||||
nanobot does not create an AI provider account for you. Before setup, have these details nearby:
|
If `py` works but `python` does not, replace `python` with `py` in the commands below.
|
||||||
|
|
||||||
1. The provider or company endpoint name.
|
If macOS or Linux says `python` is not found, try:
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
```bash
|
||||||
|
python3 --version
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Install nanobot
|
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`.
|
||||||
|
|
||||||
Copy the command for your system, paste it into the terminal, and press Enter. Copy only the text inside the code block.
|
## 3. Get a Provider API Key
|
||||||
|
|
||||||
|
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**
|
||||||
|
|
||||||
@@ -70,13 +84,75 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`:
|
||||||
|
|
||||||
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.
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
## 4. Follow Quick Start
|
```powershell
|
||||||
|
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
The wizard shows a menu similar to:
|
Use the development installer only when a maintainer asks you to test the current `main` branch:
|
||||||
|
|
||||||
|
```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?
|
||||||
@@ -85,97 +161,259 @@ The wizard shows a menu similar to:
|
|||||||
[X] Exit
|
[X] Exit
|
||||||
```
|
```
|
||||||
|
|
||||||
Choose **Quick Start**. Use the arrow keys to highlight an option and press `Enter`.
|
Move through the wizard like this:
|
||||||
|
|
||||||
The wizard asks for only the information needed for the first reply:
|
| When you see | Do this |
|
||||||
|
|---|---|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
1. Choose your provider.
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
When you paste a password or API key, the terminal may hide the characters. That is normal.
|
1. Choose `[Q] Quick Start`.
|
||||||
|
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.
|
||||||
|
|
||||||
If the installer finishes without opening the wizard and `nanobot` is available, run:
|
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.
|
||||||
|
|
||||||
```bash
|
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`.
|
||||||
nanobot onboard --wizard
|
|
||||||
|
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": { "...": "..." }
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Merge them into one object:
|
||||||
|
|
||||||
## 5. Open the Browser
|
```json
|
||||||
|
{
|
||||||
Run:
|
"providers": {
|
||||||
|
"custom": {
|
||||||
```bash
|
"apiKey": "your-api-key",
|
||||||
nanobot gateway
|
"apiBase": "https://api.example.com/v1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"websocket": {
|
||||||
|
"tokenIssueSecret": "your-webui-password",
|
||||||
|
"websocketRequiresToken": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Send this message:
|
## 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
|
||||||
|
open -e ~/.nanobot/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xdg-open ~/.nanobot/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If this is a brand-new install and you have not configured anything else yet, replace the file with this minimal config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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!
|
||||||
```
|
```
|
||||||
|
|
||||||
A normal assistant reply means setup is complete. The exact reply does not matter.
|
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:
|
||||||
|
|
||||||
The first-run address is local to your computer. It is not automatically available to other computers on your network.
|
```text
|
||||||
|
Hello! How can I help you today?
|
||||||
## 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
|
|
||||||
nanobot --version
|
|
||||||
nanobot status
|
|
||||||
nanobot agent -m "Hello!"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| What you see | What it usually means |
|
If `nanobot` is not found, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m nanobot webui
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `python3 -m nanobot webui` or `py -m nanobot webui` if that is the Python command that worked in step 2.
|
||||||
|
|
||||||
|
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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot: command not found` | Reuse the exact nanobot command printed by the installer; it points to the isolated environment that contains the package |
|
| `JSON parse error` | The config file has a missing comma, extra comma, or mismatched brace. Copy the example again. |
|
||||||
| `401`, unauthorized, or invalid API key | The key is wrong, expired, or belongs to a different provider |
|
| `401`, `unauthorized`, or `invalid API key` | The API key is wrong, expired, has extra spaces, or was pasted under the wrong provider. |
|
||||||
| Model not found | The model ID is misspelled or unavailable to your provider account |
|
| `model not found` | Your account cannot use the default model. Return to `nanobot onboard --wizard`, choose `Advanced Settings`, then edit `Model Presets`. |
|
||||||
| Browser does not open | Open `http://127.0.0.1:8765` yourself and keep the terminal running |
|
| `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 opens but messages fail | Test `nanobot agent -m "Hello!"` to separate a model problem from a WebUI problem |
|
| No response after editing config | Restart the command. Long-running processes read config when they start. |
|
||||||
| A change was saved but nothing changed | Restart nanobot so the running process reloads the config |
|
|
||||||
|
|
||||||
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.
|
For a fuller diagnosis path, see [`troubleshooting.md`](./troubleshooting.md).
|
||||||
|
|
||||||
Continue with the full [Troubleshooting guide](./troubleshooting.md) for an ordered diagnosis.
|
## What Not to Configure Yet
|
||||||
|
|
||||||
## Open nanobot Later
|
Skip these until the first local message works:
|
||||||
|
|
||||||
|
- `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
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
4. Leave the gateway terminal open, then send a message from the allowed account.
|
||||||
|
|
||||||
|
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>.
|
||||||
|
|||||||
+3
-30
@@ -53,18 +53,6 @@ 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 |
|
||||||
@@ -74,7 +62,6 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
|
|||||||
| 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 |
|
||||||
@@ -126,20 +113,6 @@ 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
|
||||||
@@ -221,9 +194,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, providers,
|
runtime configuration. Use it to review or adjust model presets, provider
|
||||||
image generation, voice transcription, web tools, chat channels, Apps,
|
visibility, image generation, voice transcription, web tools, Apps, Automations,
|
||||||
Automations, Skills, runtime identity, and advanced safety controls.
|
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,44 +1,5 @@
|
|||||||
#!/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
|
||||||
@@ -51,5 +12,4 @@ Fix (pick one):
|
|||||||
EOF
|
EOF
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec nanobot "$@"
|
exec nanobot "$@"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 657 KiB After Width: | Height: | Size: 287 KiB |
@@ -425,14 +425,9 @@ 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 _tool_result_compaction_message(message: dict[str, Any]) -> str:
|
def _summary_for(message: dict[str, Any]) -> str:
|
||||||
name = message.get("name", "tool")
|
name = message.get("name", "tool")
|
||||||
return (
|
return f"[Prior {name} result compacted to fit context; the tool call already completed.]"
|
||||||
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,
|
||||||
@@ -467,12 +462,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
|
||||||
compaction_message = self._tool_result_compaction_message(msg)
|
summary = self._summary_for(msg)
|
||||||
if msg.get("content") == compaction_message:
|
if msg.get("content") == summary:
|
||||||
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"] = compaction_message
|
updated[idx]["content"] = summary
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
def _inflight_compaction_candidates(
|
def _inflight_compaction_candidates(
|
||||||
@@ -505,4 +500,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._tool_result_compaction_message(messages[idx])
|
messages[idx]["content"] = self._summary_for(messages[idx])
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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
|
||||||
@@ -81,7 +80,6 @@ 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
|
||||||
@@ -357,7 +355,6 @@ 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,
|
||||||
@@ -543,7 +540,6 @@ 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,
|
||||||
@@ -999,11 +995,8 @@ 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 task_is_cancelling():
|
if not self._running or asyncio.current_task().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,12 +29,6 @@ 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
|
||||||
@@ -498,10 +492,14 @@ class MemoryStore:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def dream_prompt_file(self) -> Path:
|
def dream_prompt_file(self) -> Path:
|
||||||
return workspace_prompt_file(self.workspace, "dream")
|
return self.workspace / "prompts" / "dream.md"
|
||||||
|
|
||||||
def has_dream_prompt_override(self) -> bool:
|
def has_dream_prompt_override(self) -> bool:
|
||||||
return has_workspace_prompt_override(self.dream_prompt_file)
|
with suppress(OSError):
|
||||||
|
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:
|
||||||
@@ -514,19 +512,20 @@ class MemoryStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _dream_template(self) -> str:
|
def _dream_template(self) -> str:
|
||||||
text, original_chars = load_workspace_prompt_override(self.dream_prompt_file)
|
with suppress(OSError):
|
||||||
if text is not None:
|
text = self.dream_prompt_file.read_text(encoding="utf-8")
|
||||||
if (
|
if text.strip():
|
||||||
original_chars > WORKSPACE_PROMPT_MAX_CHARS
|
text = text.rstrip()
|
||||||
and not self._dream_prompt_oversize_logged
|
if len(text) > _DREAM_PROMPT_MAX_CHARS:
|
||||||
):
|
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.",
|
||||||
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
|
_DREAM_PROMPT_MAX_CHARS, len(text),
|
||||||
)
|
)
|
||||||
return text
|
return truncate_text(text, _DREAM_PROMPT_MAX_CHARS)
|
||||||
|
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:
|
||||||
@@ -735,6 +734,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-18
@@ -806,17 +806,11 @@ class AgentRunner:
|
|||||||
else:
|
else:
|
||||||
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
||||||
|
|
||||||
# Streaming requests also have provider-level idle timeouts
|
# Streaming requests already have provider-level idle timeouts
|
||||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
|
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
|
||||||
# very slow deltas can still run forever. Use a more generous wall-clock
|
# LLM timeout here, or healthy long reasoning streams can be killed just
|
||||||
# timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an
|
# because total elapsed time exceeded NANOBOT_LLM_TIMEOUT_S.
|
||||||
# opt-out for all LLM wall-clock timeouts.
|
outer_timeout_s = None if (wants_streaming or wants_progress_streaming) else timeout_s
|
||||||
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
|
||||||
@@ -824,17 +818,16 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
if outer_timeout_s is None:
|
if outer_timeout_s is None:
|
||||||
response = LLMResponse(
|
return 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",
|
||||||
)
|
)
|
||||||
else:
|
return LLMResponse(
|
||||||
response = LLMResponse(
|
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
||||||
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
|
finish_reason="error",
|
||||||
finish_reason="error",
|
error_kind="timeout",
|
||||||
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,7 +19,6 @@ 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
|
||||||
@@ -144,7 +143,6 @@ 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] = {}
|
||||||
@@ -206,7 +204,6 @@ 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,7 +71,6 @@ 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,7 +250,11 @@ 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 session.owner_session_key and session.owner_session_key != owner_session_key:
|
if (
|
||||||
|
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:
|
||||||
@@ -292,7 +296,9 @@ 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 session.owner_session_key == owner_session_key
|
if not 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:
|
||||||
@@ -436,7 +442,7 @@ class WriteStdinTool(Tool):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
return cls()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exclusive(self) -> bool:
|
def exclusive(self) -> bool:
|
||||||
@@ -580,7 +586,7 @@ class ListExecSessionsTool(Tool):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
return cls(manager=getattr(ctx, "exec_session_manager", None))
|
return cls()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ 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,7 +30,6 @@ 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
|
||||||
@@ -488,7 +487,8 @@ 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).
|
||||||
if task_is_cancelling():
|
task = asyncio.current_task()
|
||||||
|
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,7 +650,8 @@ 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:
|
||||||
if task_is_cancelling():
|
task = asyncio.current_task()
|
||||||
|
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)"
|
||||||
@@ -763,7 +764,8 @@ 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:
|
||||||
if task_is_cancelling():
|
task = asyncio.current_task()
|
||||||
|
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)"
|
||||||
@@ -1143,8 +1145,6 @@ 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)
|
||||||
@@ -1162,9 +1162,9 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
config = load_effective_config()
|
||||||
next_servers = dict(config.tools.mcp_servers)
|
next_servers = dict(config.tools.mcp_servers)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
@@ -1410,10 +1410,6 @@ 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)
|
||||||
|
|
||||||
@@ -1427,9 +1423,5 @@ 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)
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ from nanobot.security.workspace_policy import is_path_within
|
|||||||
|
|
||||||
_IS_WINDOWS = sys.platform == "win32"
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
_RM_COMMAND_RE = re.compile(r"\brm\b")
|
|
||||||
_SHELL_COMMAND_SEPARATOR_RE = re.compile(r"(?:&&|\|\||[;&|\r\n])")
|
|
||||||
_SHELL_TOKEN_RE = re.compile(r'''"[^"]*"|'[^']*'|[^\s]+''')
|
|
||||||
|
|
||||||
|
|
||||||
def _reap_pid(pid: int) -> None:
|
def _reap_pid(pid: int) -> None:
|
||||||
"""Best-effort ``waitpid`` to reap a child and prevent zombies.
|
"""Best-effort ``waitpid`` to reap a child and prevent zombies.
|
||||||
@@ -192,7 +188,6 @@ 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__(
|
||||||
@@ -214,6 +209,7 @@ class ExecTool(Tool):
|
|||||||
self.working_dir = working_dir
|
self.working_dir = working_dir
|
||||||
self.sandbox = sandbox
|
self.sandbox = sandbox
|
||||||
self.deny_patterns = (deny_patterns or []) + [
|
self.deny_patterns = (deny_patterns or []) + [
|
||||||
|
r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr
|
||||||
r"\bdel\s+/[fq]\b", # del /f, del /q
|
r"\bdel\s+/[fq]\b", # del /f, del /q
|
||||||
r"\brmdir\s+/s\b", # rmdir /s
|
r"\brmdir\s+/s\b", # rmdir /s
|
||||||
r"(?:^|[;&|]\s*)format(?!=)\b", # format (as standalone command only)
|
r"(?:^|[;&|]\s*)format(?!=)\b", # format (as standalone command only)
|
||||||
@@ -539,12 +535,7 @@ class ExecTool(Tool):
|
|||||||
env=cmd_env,
|
env=cmd_env,
|
||||||
)
|
)
|
||||||
command = ExecTool._normalize_powershell_command(command)
|
command = ExecTool._normalize_powershell_command(command)
|
||||||
command = (
|
command = f"{command}\nif ($LASTEXITCODE -ne $null) {{ exit $LASTEXITCODE }}"
|
||||||
"[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,
|
||||||
@@ -707,74 +698,6 @@ class ExecTool(Tool):
|
|||||||
env[key] = val
|
env[key] = val
|
||||||
return env
|
return env
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _contains_unscoped_recursive_rm(cls, command: str) -> bool:
|
|
||||||
"""Return whether ``command`` contains recursive rm outside a scoped /tmp target.
|
|
||||||
|
|
||||||
The exec guard deliberately remains conservative for recursive deletion, but
|
|
||||||
test and build scripts routinely clean their own named directories below
|
|
||||||
``/tmp``. Treat only static, direct ``/tmp/<name>`` targets as scoped cleanup.
|
|
||||||
Any ambiguous invocation (variables, traversal, broad globs, nested paths,
|
|
||||||
mixed targets) stays blocked.
|
|
||||||
"""
|
|
||||||
for match in _RM_COMMAND_RE.finditer(command):
|
|
||||||
tail = command[match.end():]
|
|
||||||
segment = _SHELL_COMMAND_SEPARATOR_RE.split(tail, maxsplit=1)[0]
|
|
||||||
tokens = _SHELL_TOKEN_RE.findall(segment)
|
|
||||||
recursive = False
|
|
||||||
targets: list[str] = []
|
|
||||||
parsing_options = True
|
|
||||||
unsafe_redirect = False
|
|
||||||
|
|
||||||
for raw_token in tokens:
|
|
||||||
token = raw_token.strip().strip("\"'")
|
|
||||||
if not token:
|
|
||||||
continue
|
|
||||||
if token == "--" and parsing_options:
|
|
||||||
parsing_options = False
|
|
||||||
continue
|
|
||||||
if parsing_options and token.startswith("--"):
|
|
||||||
recursive = recursive or token == "--recursive"
|
|
||||||
continue
|
|
||||||
if parsing_options and re.fullmatch(r"-[a-z]+", token):
|
|
||||||
recursive = recursive or "r" in token[1:]
|
|
||||||
continue
|
|
||||||
|
|
||||||
parsing_options = False
|
|
||||||
if token.startswith("#"):
|
|
||||||
break
|
|
||||||
if re.match(r"^\d*[<>]", token):
|
|
||||||
redirect_target = re.sub(r"^\d*[<>]+", "", token)
|
|
||||||
if redirect_target and redirect_target != "/dev/null":
|
|
||||||
unsafe_redirect = True
|
|
||||||
continue
|
|
||||||
targets.append(token)
|
|
||||||
|
|
||||||
if recursive and (
|
|
||||||
unsafe_redirect
|
|
||||||
or not targets
|
|
||||||
or not all(cls._is_scoped_tmp_cleanup_target(target) for target in targets)
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_scoped_tmp_cleanup_target(raw_target: str) -> bool:
|
|
||||||
"""Accept a static, specifically named descendant of the POSIX /tmp root."""
|
|
||||||
target = raw_target.strip().rstrip("\"'),")
|
|
||||||
if not target.startswith("/tmp/"):
|
|
||||||
return False
|
|
||||||
|
|
||||||
relative = target.removeprefix("/tmp/")
|
|
||||||
if not relative or any(char in relative for char in ("$", "`", "\\", "[", "{")):
|
|
||||||
return False
|
|
||||||
if "/" in relative or relative in {".", ".."}:
|
|
||||||
return False
|
|
||||||
|
|
||||||
literal_prefix = re.split(r"[*?]", relative, maxsplit=1)[0]
|
|
||||||
return any(char.isalnum() or char in "_-" for char in literal_prefix)
|
|
||||||
|
|
||||||
def _guard_command(
|
def _guard_command(
|
||||||
self,
|
self,
|
||||||
command: str,
|
command: str,
|
||||||
@@ -794,9 +717,6 @@ class ExecTool(Tool):
|
|||||||
re.fullmatch(p, lower) for p in self.allow_patterns
|
re.fullmatch(p, lower) for p in self.allow_patterns
|
||||||
)
|
)
|
||||||
if not explicitly_allowed:
|
if not explicitly_allowed:
|
||||||
if self._contains_unscoped_recursive_rm(lower):
|
|
||||||
return ToolResult.error("Error: Command blocked by deny pattern filter")
|
|
||||||
|
|
||||||
for pattern in self.deny_patterns:
|
for pattern in self.deny_patterns:
|
||||||
if re.search(pattern, lower):
|
if re.search(pattern, lower):
|
||||||
return ToolResult.error("Error: Command blocked by deny pattern filter")
|
return ToolResult.error("Error: Command blocked by deny pattern filter")
|
||||||
|
|||||||
@@ -315,8 +315,9 @@ class WebSearchTool(Tool):
|
|||||||
config_loader = None
|
config_loader = None
|
||||||
if ctx.provider_snapshot_loader is not None:
|
if ctx.provider_snapshot_loader is not None:
|
||||||
def config_loader():
|
def config_loader():
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
return resolve_config_env_vars(load_config()).tools.web.search
|
|
||||||
|
return load_effective_config().tools.web.search
|
||||||
return cls(
|
return cls(
|
||||||
config=ctx.config.web.search,
|
config=ctx.config.web.search,
|
||||||
proxy=ctx.config.web.proxy,
|
proxy=ctx.config.web.proxy,
|
||||||
|
|||||||
+9
-38
@@ -41,26 +41,6 @@ __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)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -229,14 +209,9 @@ 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 = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
|
agent_loop = request.app["agent_loop"]
|
||||||
timeout_s: float = _app_value(
|
timeout_s: float = request.app.get("request_timeout", 120.0)
|
||||||
request.app,
|
model_name: str = request.app.get("model_name", "nanobot")
|
||||||
_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:
|
||||||
@@ -263,11 +238,7 @@ 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] = _app_value(
|
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
|
||||||
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(
|
||||||
@@ -395,7 +366,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 = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
|
model_name = request.app.get("model_name", "nanobot")
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"object": "list",
|
"object": "list",
|
||||||
@@ -436,10 +407,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_KEY] = agent_loop
|
app["agent_loop"] = agent_loop
|
||||||
app[_MODEL_NAME_KEY] = model_name
|
app["model_name"] = model_name
|
||||||
app[_REQUEST_TIMEOUT_KEY] = request_timeout
|
app["request_timeout"] = request_timeout
|
||||||
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
|
app["session_locks"] = {} # 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,8 +188,6 @@ _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",
|
||||||
@@ -763,30 +761,19 @@ 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(
|
"display_name": str(entry.get("display_name") or name),
|
||||||
cached_app.get("display_name") or entry.get("display_name") or name
|
"category": str(entry.get("category") or "installed"),
|
||||||
),
|
"description": str(entry.get("description") or ""),
|
||||||
"category": str(cached_app.get("category") or entry.get("category") or "installed"),
|
"requires": str(entry.get("requires") or ""),
|
||||||
"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 {
|
||||||
@@ -961,8 +948,6 @@ 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)
|
||||||
@@ -981,17 +966,6 @@ 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
|
||||||
@@ -1366,8 +1340,6 @@ 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,5 +1,6 @@
|
|||||||
"""Shared contracts for chat channels."""
|
"""Chat channels module with plugin architecture."""
|
||||||
|
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.channels.manager import ChannelManager
|
||||||
|
|
||||||
__all__ = ["BaseChannel"]
|
__all__ = ["BaseChannel", "ChannelManager"]
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
"""Feishu-owned helpers for its persisted multi-instance configuration."""
|
"""Helpers for channel instance configuration.
|
||||||
|
|
||||||
|
The first consumer is Feishu/Lark. Keep the helpers small and data-oriented so
|
||||||
|
ChannelManager can support Feishu assistant instances without turning every
|
||||||
|
channel into a multi-instance abstraction.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
|
|
||||||
from nanobot.channels.feishu.config import feishu_default_config
|
|
||||||
from nanobot.config.loader import merge_missing_defaults
|
from nanobot.config.loader import merge_missing_defaults
|
||||||
|
|
||||||
DEFAULT_INSTANCE_ID = "default"
|
DEFAULT_INSTANCE_ID = "default"
|
||||||
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelInstanceSpec:
|
||||||
|
"""Runtime description for one channel instance."""
|
||||||
|
|
||||||
|
base_name: str
|
||||||
|
instance_id: str
|
||||||
|
runtime_name: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
def validate_instance_id(value: str) -> str:
|
def validate_instance_id(value: str) -> str:
|
||||||
"""Return a normalized instance id or raise ValueError."""
|
"""Return a normalized instance id or raise ValueError."""
|
||||||
instance_id = value.strip()
|
instance_id = value.strip()
|
||||||
@@ -28,33 +42,6 @@ def runtime_channel_name(base_name: str, instance_id: str) -> str:
|
|||||||
return base_name if instance_id == DEFAULT_INSTANCE_ID else f"{base_name}.{instance_id}"
|
return base_name if instance_id == DEFAULT_INSTANCE_ID else f"{base_name}.{instance_id}"
|
||||||
|
|
||||||
|
|
||||||
def managed_feishu_instance_specs(
|
|
||||||
section: Any,
|
|
||||||
*,
|
|
||||||
enabled_only: bool = True,
|
|
||||||
) -> list[ChannelInstanceSpec]:
|
|
||||||
return feishu_instance_specs(
|
|
||||||
section,
|
|
||||||
feishu_default_config(),
|
|
||||||
enabled_only=enabled_only,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_managed_feishu_instance(
|
|
||||||
section: Any,
|
|
||||||
values: dict[str, Any],
|
|
||||||
*,
|
|
||||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
existing = section if isinstance(section, dict) else {}
|
|
||||||
return upsert_feishu_instance(
|
|
||||||
existing,
|
|
||||||
feishu_default_config(),
|
|
||||||
instance_id,
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _base_feishu_instance_config(defaults: dict[str, Any]) -> dict[str, Any]:
|
def _base_feishu_instance_config(defaults: dict[str, Any]) -> dict[str, Any]:
|
||||||
config = dict(defaults)
|
config = dict(defaults)
|
||||||
config["instanceId"] = DEFAULT_INSTANCE_ID
|
config["instanceId"] = DEFAULT_INSTANCE_ID
|
||||||
@@ -80,31 +67,6 @@ def _normalize_feishu_instance(
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def feishu_app_identity_key(app_id: Any, domain: Any = "feishu") -> str:
|
|
||||||
"""Return the stable identity shared by persisted and runtime instances."""
|
|
||||||
app_id = str(app_id or "").strip()
|
|
||||||
if not app_id:
|
|
||||||
return ""
|
|
||||||
normalized_domain = "lark" if str(domain or "feishu").strip().lower() == "lark" else "feishu"
|
|
||||||
return f"{normalized_domain}:{app_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _feishu_instance_inputs(
|
|
||||||
section: Any,
|
|
||||||
defaults: dict[str, Any],
|
|
||||||
) -> tuple[list[Any], dict[str, Any] | None]:
|
|
||||||
if hasattr(section, "model_dump"):
|
|
||||||
section = section.model_dump(mode="json", by_alias=True)
|
|
||||||
if not isinstance(section, dict):
|
|
||||||
section = {}
|
|
||||||
|
|
||||||
instances = section.get("instances")
|
|
||||||
if isinstance(instances, list):
|
|
||||||
inherited = {key: value for key, value in section.items() if key != "instances"}
|
|
||||||
return list(instances), inherited
|
|
||||||
return ([section] if section else [_base_feishu_instance_config(defaults)]), None
|
|
||||||
|
|
||||||
|
|
||||||
def feishu_instance_specs(
|
def feishu_instance_specs(
|
||||||
section: Any,
|
section: Any,
|
||||||
defaults: dict[str, Any],
|
defaults: dict[str, Any],
|
||||||
@@ -112,15 +74,22 @@ def feishu_instance_specs(
|
|||||||
enabled_only: bool = False,
|
enabled_only: bool = False,
|
||||||
) -> list[ChannelInstanceSpec]:
|
) -> list[ChannelInstanceSpec]:
|
||||||
"""Expand legacy or canonical Feishu config into runtime instance specs."""
|
"""Expand legacy or canonical Feishu config into runtime instance specs."""
|
||||||
raw_specs, inherited = _feishu_instance_inputs(section, defaults)
|
if hasattr(section, "model_dump"):
|
||||||
|
section = section.model_dump(mode="json", by_alias=True)
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
section = {}
|
||||||
|
|
||||||
|
instances = section.get("instances")
|
||||||
|
raw_specs: list[dict[str, Any]]
|
||||||
|
inherited: dict[str, Any] | None = None
|
||||||
|
if isinstance(instances, list):
|
||||||
|
inherited = {key: value for key, value in section.items() if key != "instances"}
|
||||||
|
raw_specs = [item for item in instances if isinstance(item, dict)]
|
||||||
|
else:
|
||||||
|
raw_specs = [section] if section else [_base_feishu_instance_config(defaults)]
|
||||||
|
|
||||||
specs: list[ChannelInstanceSpec] = []
|
specs: list[ChannelInstanceSpec] = []
|
||||||
instance_ids: set[str] = set()
|
|
||||||
identity_owners: dict[str, str] = {}
|
|
||||||
for index, raw in enumerate(raw_specs):
|
for index, raw in enumerate(raw_specs):
|
||||||
if not isinstance(raw, dict):
|
|
||||||
logger.warning("Skipping invalid Feishu instance at index {}: expected an object", index)
|
|
||||||
continue
|
|
||||||
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
||||||
try:
|
try:
|
||||||
config = _normalize_feishu_instance(
|
config = _normalize_feishu_instance(
|
||||||
@@ -133,33 +102,16 @@ def feishu_instance_specs(
|
|||||||
logger.warning("Skipping invalid Feishu instance config: {}", exc)
|
logger.warning("Skipping invalid Feishu instance config: {}", exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
instance_id = str(config["instanceId"])
|
|
||||||
if instance_id in instance_ids:
|
|
||||||
logger.warning("Skipping duplicate Feishu instance id '{}'", instance_id)
|
|
||||||
continue
|
|
||||||
|
|
||||||
instance_ids.add(instance_id)
|
|
||||||
enabled = bool(config.get("enabled", defaults.get("enabled", False)))
|
enabled = bool(config.get("enabled", defaults.get("enabled", False)))
|
||||||
if enabled_only and not enabled:
|
if enabled_only and not enabled:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
identity = feishu_app_identity_key(
|
instance_id = str(config["instanceId"])
|
||||||
config.get("appId") or config.get("app_id"),
|
|
||||||
config.get("domain"),
|
|
||||||
)
|
|
||||||
if enabled_only and identity:
|
|
||||||
if identity in identity_owners:
|
|
||||||
logger.warning(
|
|
||||||
"Skipping Feishu instance '{}' because it uses the same app as instance '{}'",
|
|
||||||
instance_id,
|
|
||||||
identity_owners[identity],
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
identity_owners[identity] = instance_id
|
|
||||||
|
|
||||||
specs.append(
|
specs.append(
|
||||||
ChannelInstanceSpec(
|
ChannelInstanceSpec(
|
||||||
|
base_name="feishu",
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
|
runtime_name=runtime_channel_name("feishu", instance_id),
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -168,32 +120,9 @@ def feishu_instance_specs(
|
|||||||
|
|
||||||
|
|
||||||
def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str, Any]:
|
def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Return a canonical section, rejecting input that cannot be preserved safely."""
|
"""Return Feishu config in the canonical ``instances`` shape."""
|
||||||
raw_specs, inherited = _feishu_instance_inputs(section, defaults)
|
specs = feishu_instance_specs(section, defaults)
|
||||||
instances: list[dict[str, Any]] = []
|
return {"instances": [dict(spec.config) for spec in specs]}
|
||||||
instance_ids: set[str] = set()
|
|
||||||
|
|
||||||
for index, raw in enumerate(raw_specs):
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"Feishu instance at index {index} must be an object")
|
|
||||||
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
|
|
||||||
try:
|
|
||||||
config = _normalize_feishu_instance(
|
|
||||||
raw,
|
|
||||||
defaults,
|
|
||||||
inherited=inherited,
|
|
||||||
fallback_id=fallback_id,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError(f"Invalid Feishu instance at index {index}: {exc}") from exc
|
|
||||||
|
|
||||||
instance_id = str(config["instanceId"])
|
|
||||||
if instance_id in instance_ids:
|
|
||||||
raise ValueError(f"duplicate Feishu instance id '{instance_id}'")
|
|
||||||
instance_ids.add(instance_id)
|
|
||||||
instances.append(config)
|
|
||||||
|
|
||||||
return {"instances": instances}
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_feishu_instance(
|
def upsert_feishu_instance(
|
||||||
@@ -245,23 +174,11 @@ def update_feishu_instance_preserving_shape(
|
|||||||
return upsert_feishu_instance(section, defaults, instance_id, values)
|
return upsert_feishu_instance(section, defaults, instance_id, values)
|
||||||
|
|
||||||
|
|
||||||
FEISHU_MANAGEMENT = ChannelManagementSpec(
|
def set_feishu_instance_enabled(
|
||||||
multi_instance=True,
|
section: Any,
|
||||||
default_config=feishu_default_config,
|
defaults: dict[str, Any],
|
||||||
instance_specs=managed_feishu_instance_specs,
|
instance_id: str,
|
||||||
update_instance_config=update_managed_feishu_instance,
|
enabled: bool,
|
||||||
runtime_name=runtime_channel_name,
|
) -> dict[str, Any]:
|
||||||
)
|
"""Return canonical Feishu section with one instance's enabled flag updated."""
|
||||||
|
return upsert_feishu_instance(section, defaults, instance_id, {"enabled": enabled})
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DEFAULT_INSTANCE_ID",
|
|
||||||
"FEISHU_MANAGEMENT",
|
|
||||||
"canonical_feishu_section",
|
|
||||||
"feishu_app_identity_key",
|
|
||||||
"feishu_instance_specs",
|
|
||||||
"runtime_channel_name",
|
|
||||||
"update_feishu_instance_preserving_shape",
|
|
||||||
"upsert_feishu_instance",
|
|
||||||
"validate_instance_id",
|
|
||||||
]
|
|
||||||
@@ -10,35 +10,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Any, Protocol
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
class _LarkWsClient(Protocol):
|
|
||||||
"""Private SDK surface isolated behind the Feishu runtime adapter."""
|
|
||||||
|
|
||||||
_auto_reconnect: bool
|
|
||||||
_receive_message_loop: Callable[[], Awaitable[None]]
|
|
||||||
|
|
||||||
async def _connect(self) -> None: ...
|
|
||||||
|
|
||||||
async def _disconnect(self) -> None: ...
|
|
||||||
|
|
||||||
async def _ping_loop(self) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _ClientRuntime:
|
class _ClientRuntime:
|
||||||
client: _LarkWsClient
|
client: Any
|
||||||
stop_event: asyncio.Event
|
stop_event: asyncio.Event
|
||||||
task: asyncio.Task[Any] | None
|
task: asyncio.Task
|
||||||
receive_loop: Callable[[], Awaitable[None]]
|
|
||||||
auto_reconnect: bool
|
|
||||||
receive_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
|
|
||||||
|
|
||||||
|
|
||||||
class FeishuWsRunner:
|
class FeishuWsRunner:
|
||||||
@@ -51,7 +34,7 @@ class FeishuWsRunner:
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._clients: dict[str, _ClientRuntime] = {}
|
self._clients: dict[str, _ClientRuntime] = {}
|
||||||
|
|
||||||
async def start_client(self, key: str, client: _LarkWsClient) -> None:
|
async def start_client(self, key: str, client: Any) -> None:
|
||||||
"""Start or replace one client runtime."""
|
"""Start or replace one client runtime."""
|
||||||
loop = self._ensure_loop()
|
loop = self._ensure_loop()
|
||||||
await asyncio.wrap_future(
|
await asyncio.wrap_future(
|
||||||
@@ -91,63 +74,24 @@ class FeishuWsRunner:
|
|||||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
async def _start_client(self, key: str, client: _LarkWsClient) -> None:
|
async def _start_client(self, key: str, client: Any) -> None:
|
||||||
await self._stop_client(key)
|
await self._stop_client(key)
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
receive_loop = client._receive_message_loop
|
task = asyncio.create_task(self._client_main(key, client, stop_event))
|
||||||
runtime = _ClientRuntime(
|
self._clients[key] = _ClientRuntime(client=client, stop_event=stop_event, task=task)
|
||||||
client=client,
|
|
||||||
stop_event=stop_event,
|
|
||||||
task=None,
|
|
||||||
receive_loop=receive_loop,
|
|
||||||
auto_reconnect=client._auto_reconnect,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The SDK discards this task handle. Track it at the adapter boundary so
|
|
||||||
# an intentional stop can cancel recv() before closing the socket; otherwise
|
|
||||||
# the SDK logs close code 1000 as an error and starts an unwanted reconnect.
|
|
||||||
async def tracked_receive_loop() -> None:
|
|
||||||
if stop_event.is_set():
|
|
||||||
return
|
|
||||||
task = asyncio.current_task()
|
|
||||||
if task is not None:
|
|
||||||
runtime.receive_tasks.add(task)
|
|
||||||
try:
|
|
||||||
await receive_loop()
|
|
||||||
finally:
|
|
||||||
if task is not None:
|
|
||||||
runtime.receive_tasks.discard(task)
|
|
||||||
|
|
||||||
client._receive_message_loop = tracked_receive_loop
|
|
||||||
runtime.task = asyncio.create_task(self._client_main(key, client, stop_event))
|
|
||||||
self._clients[key] = runtime
|
|
||||||
|
|
||||||
async def _stop_client(self, key: str) -> None:
|
async def _stop_client(self, key: str) -> None:
|
||||||
runtime = self._clients.pop(key, None)
|
runtime = self._clients.pop(key, None)
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
return
|
return
|
||||||
runtime.stop_event.set()
|
runtime.stop_event.set()
|
||||||
runtime.client._auto_reconnect = False
|
with suppress(Exception):
|
||||||
try:
|
await runtime.client._disconnect()
|
||||||
receive_tasks = tuple(runtime.receive_tasks)
|
runtime.task.cancel()
|
||||||
for task in receive_tasks:
|
with suppress(asyncio.CancelledError):
|
||||||
task.cancel()
|
await runtime.task
|
||||||
if receive_tasks:
|
|
||||||
await asyncio.gather(*receive_tasks, return_exceptions=True)
|
|
||||||
|
|
||||||
if runtime.task is not None:
|
async def _client_main(self, key: str, client: Any, stop_event: asyncio.Event) -> None:
|
||||||
runtime.task.cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await runtime.task
|
|
||||||
with suppress(Exception):
|
|
||||||
await runtime.client._disconnect()
|
|
||||||
finally:
|
|
||||||
runtime.client._receive_message_loop = runtime.receive_loop
|
|
||||||
runtime.client._auto_reconnect = runtime.auto_reconnect
|
|
||||||
|
|
||||||
async def _client_main(
|
|
||||||
self, key: str, client: _LarkWsClient, stop_event: asyncio.Event
|
|
||||||
) -> None:
|
|
||||||
ping_task: asyncio.Task | None = None
|
ping_task: asyncio.Task | None = None
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""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)
|
|
||||||
+335
-15
@@ -1,23 +1,343 @@
|
|||||||
"""Resolve channel-owned setup contracts for settings consumers."""
|
"""Shared channel setup contract for configuration, display, and validation."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
from nanobot.channels.contracts import ChannelSetupSpec
|
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||||
|
RouteFieldType = str | tuple[str, set[str]]
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.channels.plugin import ChannelPlugin
|
|
||||||
|
|
||||||
|
|
||||||
def channel_setup_spec(
|
@dataclass(frozen=True)
|
||||||
name: str,
|
class ChannelFieldSpec:
|
||||||
|
"""One channel field exposed through the settings contract."""
|
||||||
|
|
||||||
|
kind: FieldKind = "string"
|
||||||
|
choices: frozenset[str] = frozenset()
|
||||||
|
writable: bool = True
|
||||||
|
snapshot: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def route_type(self) -> RouteFieldType:
|
||||||
|
if self.kind == "enum":
|
||||||
|
return ("enum", set(self.choices))
|
||||||
|
return self.kind
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SetupRequirement:
|
||||||
|
"""A requirement satisfied by any one complete field group."""
|
||||||
|
|
||||||
|
alternatives: tuple[tuple[str, ...], ...]
|
||||||
|
|
||||||
|
def is_satisfied(self, values: Any) -> bool:
|
||||||
|
return any(
|
||||||
|
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||||
|
for group in self.alternatives
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simple_field(self) -> str | None:
|
||||||
|
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||||
|
return self.alternatives[0][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChannelSetupSpec:
|
||||||
|
"""Save, display, and validation contract for one channel."""
|
||||||
|
|
||||||
|
fields: dict[str, ChannelFieldSpec]
|
||||||
|
required: tuple[SetupRequirement, ...] = ()
|
||||||
|
official_url: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secrets(self) -> frozenset[str]:
|
||||||
|
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snapshot_fields(self) -> tuple[str, ...]:
|
||||||
|
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||||
|
return {
|
||||||
|
name: field.route_type
|
||||||
|
for name, field in self.fields.items()
|
||||||
|
if field.writable
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def simple_required_fields(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
field
|
||||||
|
for requirement in self.required
|
||||||
|
if (field := requirement.simple_field) is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_configured(self, values: Any) -> bool:
|
||||||
|
return bool(self.required) and all(
|
||||||
|
requirement.is_satisfied(values) for requirement in self.required
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _field(
|
||||||
|
kind: FieldKind = "string",
|
||||||
*,
|
*,
|
||||||
plugin: ChannelPlugin | None = None,
|
choices: set[str] | None = None,
|
||||||
) -> ChannelSetupSpec | None:
|
writable: bool = True,
|
||||||
"""Return the setup contract declared by one channel descriptor."""
|
snapshot: bool = True,
|
||||||
if plugin is None:
|
) -> ChannelFieldSpec:
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
return ChannelFieldSpec(
|
||||||
|
kind=kind,
|
||||||
|
choices=frozenset(choices or ()),
|
||||||
|
writable=writable,
|
||||||
|
snapshot=snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
plugin = load_channel_plugin(name)
|
|
||||||
return plugin.setup
|
def _required(field: str) -> SetupRequirement:
|
||||||
|
return SetupRequirement(((field,),))
|
||||||
|
|
||||||
|
|
||||||
|
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||||
|
return SetupRequirement(alternatives)
|
||||||
|
|
||||||
|
|
||||||
|
_GROUP_POLICIES = {"mention", "open", "allowlist"}
|
||||||
|
_DIRECT_GROUP_POLICIES = {"mention", "open"}
|
||||||
|
|
||||||
|
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
|
||||||
|
"websocket": ChannelSetupSpec(
|
||||||
|
fields={},
|
||||||
|
official_url="http://127.0.0.1:8765",
|
||||||
|
),
|
||||||
|
"telegram": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("token"),),
|
||||||
|
official_url="https://t.me/BotFather",
|
||||||
|
),
|
||||||
|
"slack": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appToken": _field("secret"),
|
||||||
|
"botToken": _field("secret"),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("appToken"), _required("botToken")),
|
||||||
|
official_url="https://api.slack.com/apps",
|
||||||
|
),
|
||||||
|
"discord": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": _field("secret"),
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"allowChannels": _field("list"),
|
||||||
|
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("token"),),
|
||||||
|
official_url="https://discord.com/developers/applications",
|
||||||
|
),
|
||||||
|
"email": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"consentGranted": _field("bool"),
|
||||||
|
"imapHost": _field(),
|
||||||
|
"imapPort": _field("int"),
|
||||||
|
"imapUsername": _field(),
|
||||||
|
"imapPassword": _field("secret"),
|
||||||
|
"smtpHost": _field(),
|
||||||
|
"smtpPort": _field("int"),
|
||||||
|
"smtpUsername": _field(),
|
||||||
|
"smtpPassword": _field("secret"),
|
||||||
|
"fromAddress": _field(),
|
||||||
|
"pollIntervalSeconds": _field("int"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"verifyDkim": _field("bool"),
|
||||||
|
"verifySpf": _field("bool"),
|
||||||
|
},
|
||||||
|
required=tuple(
|
||||||
|
_required(field)
|
||||||
|
for field in (
|
||||||
|
"consentGranted",
|
||||||
|
"imapHost",
|
||||||
|
"imapUsername",
|
||||||
|
"imapPassword",
|
||||||
|
"smtpHost",
|
||||||
|
"smtpUsername",
|
||||||
|
"smtpPassword",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
official_url="https://support.google.com/accounts/answer/185833",
|
||||||
|
),
|
||||||
|
"matrix": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"homeserver": _field(),
|
||||||
|
"userId": _field(),
|
||||||
|
"password": _field("secret"),
|
||||||
|
"accessToken": _field("secret"),
|
||||||
|
"deviceId": _field(),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
"allowFrom": _field("list", writable=False),
|
||||||
|
},
|
||||||
|
required=(
|
||||||
|
_required("homeserver"),
|
||||||
|
_required("userId"),
|
||||||
|
_one_of(("password",), ("accessToken", "deviceId")),
|
||||||
|
),
|
||||||
|
official_url="https://matrix.org/ecosystem/clients/",
|
||||||
|
),
|
||||||
|
"mattermost": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"serverUrl": _field(),
|
||||||
|
"token": _field("secret"),
|
||||||
|
"teamId": _field(),
|
||||||
|
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("serverUrl"), _required("token")),
|
||||||
|
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
|
||||||
|
),
|
||||||
|
"whatsapp": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
|
||||||
|
"databasePath": _field(writable=False, snapshot=False),
|
||||||
|
},
|
||||||
|
official_url="https://faq.whatsapp.com/",
|
||||||
|
),
|
||||||
|
"dingtalk": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"clientId": _field(),
|
||||||
|
"clientSecret": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("clientId"), _required("clientSecret")),
|
||||||
|
official_url="https://open.dingtalk.com/",
|
||||||
|
),
|
||||||
|
"wecom": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"botId": _field(),
|
||||||
|
"secret": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("botId"), _required("secret")),
|
||||||
|
official_url="https://developer.work.weixin.qq.com/",
|
||||||
|
),
|
||||||
|
"weixin": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"token": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("token"),),
|
||||||
|
official_url="https://weixin.qq.com/",
|
||||||
|
),
|
||||||
|
"qq": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appId": _field(),
|
||||||
|
"secret": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"msgFormat": _field("enum", choices={"plain", "markdown"}),
|
||||||
|
},
|
||||||
|
required=(_required("appId"), _required("secret")),
|
||||||
|
official_url="https://q.qq.com/",
|
||||||
|
),
|
||||||
|
"signal": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"phoneNumber": _field(),
|
||||||
|
"daemonHost": _field(),
|
||||||
|
"daemonPort": _field("int"),
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"dm.allowFrom": _field("list"),
|
||||||
|
"group.allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("phoneNumber"),),
|
||||||
|
official_url="https://github.com/bbernhard/signal-cli-rest-api",
|
||||||
|
),
|
||||||
|
"msteams": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appId": _field(),
|
||||||
|
"appPassword": _field("secret"),
|
||||||
|
"tenantId": _field(),
|
||||||
|
"path": _field(),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
},
|
||||||
|
required=(_required("appId"), _required("appPassword")),
|
||||||
|
official_url="https://dev.teams.microsoft.com/apps",
|
||||||
|
),
|
||||||
|
"napcat": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"wsUrl": _field(),
|
||||||
|
"accessToken": _field("secret"),
|
||||||
|
"allowFrom": _field("list"),
|
||||||
|
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||||
|
},
|
||||||
|
required=(_required("wsUrl"),),
|
||||||
|
official_url="https://napneko.github.io/",
|
||||||
|
),
|
||||||
|
"feishu": ChannelSetupSpec(
|
||||||
|
fields={
|
||||||
|
"appId": _field(snapshot=False),
|
||||||
|
"appSecret": _field("secret", snapshot=False),
|
||||||
|
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
|
||||||
|
"groupPolicy": _field(
|
||||||
|
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
|
||||||
|
),
|
||||||
|
"allowFrom": _field("list", snapshot=False),
|
||||||
|
"topicIsolation": _field("bool", snapshot=False),
|
||||||
|
},
|
||||||
|
required=(_required("appId"), _required("appSecret")),
|
||||||
|
official_url="https://open.feishu.cn/app",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
|
||||||
|
return CHANNEL_SETUP_SPECS.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||||
|
current = values
|
||||||
|
for part in field_path.split("."):
|
||||||
|
candidates = (part, _camel_to_snake(part))
|
||||||
|
if isinstance(current, dict):
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in current:
|
||||||
|
current = current[candidate]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
for candidate in candidates:
|
||||||
|
if hasattr(current, candidate):
|
||||||
|
current = getattr(current, candidate)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def channel_value_present(value: Any) -> bool:
|
||||||
|
return value not in (None, "", [], {})
|
||||||
|
|
||||||
|
|
||||||
|
def stringify_channel_value(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
if isinstance(value, list):
|
||||||
|
return ", ".join(str(item) for item in value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _camel_to_snake(value: str) -> str:
|
||||||
|
chars: list[str] = []
|
||||||
|
for char in value:
|
||||||
|
if char.isupper():
|
||||||
|
if chars:
|
||||||
|
chars.append("_")
|
||||||
|
chars.append(char.lower())
|
||||||
|
else:
|
||||||
|
chars.append(char)
|
||||||
|
return "".join(chars)
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ class BaseChannel(ABC):
|
|||||||
resolve_transcription_config,
|
resolve_transcription_config,
|
||||||
transcribe_audio_file,
|
transcribe_audio_file,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
return await transcribe_audio_file(file_path, resolve_transcription_config(load_config()))
|
return await transcribe_audio_file(file_path, resolve_transcription_config(load_raw_config()))
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Audio transcription failed")
|
self.logger.exception("Audio transcription failed")
|
||||||
return ""
|
return ""
|
||||||
@@ -224,17 +224,9 @@ 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 a message after checking its authorization subject.
|
"""Handle an incoming message: check permissions, issue pairing codes in DMs, or forward to bus."""
|
||||||
|
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(
|
||||||
@@ -278,16 +270,6 @@ 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."""
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -710,11 +710,10 @@ 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:
|
||||||
raise RuntimeError("DingTalk access token unavailable")
|
return
|
||||||
|
|
||||||
if msg.content and msg.content.strip():
|
if msg.content and msg.content.strip():
|
||||||
if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
|
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)
|
||||||
@@ -723,12 +722,11 @@ 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))
|
||||||
if not await self._send_markdown_text(
|
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,
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""DingTalk channel package."""
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""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",
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for the DingTalk channel package."""
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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"])
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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(カンマ区切り)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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, 쉼표로 구분"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"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,用逗号分隔"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"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,以逗號分隔"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
raise
|
return
|
||||||
|
|
||||||
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,7 +466,8 @@ 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():
|
||||||
raise RuntimeError("Discord client is not ready")
|
self.logger.warning("client not ready; dropping outbound message")
|
||||||
|
return
|
||||||
|
|
||||||
is_progress = isinstance(msg.event, ProgressEvent)
|
is_progress = isinstance(msg.event, ProgressEvent)
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Discord channel package."""
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""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",
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for the Discord channel package."""
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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(カンマ区切り)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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, 쉼표로 구분"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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,用逗号分隔"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"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,以逗號分隔"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Email channel package."""
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""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",
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for the email channel package."""
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""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"]
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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é"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "オフ"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "꺼짐"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user