Compare commits

...
Author SHA1 Message Date
Xubin Ren ab8783758a feat(webui): add browser companion launch 2026-07-20 04:51:26 +08:00
chengyongruandchengyongru e3de01c9f6 fix(webui): resolve build runner executable 2026-07-19 23:59:34 +08:00
chengyongruandGitHub 462a0dfb0f refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
2026-07-19 23:30:49 +08:00
chengyongruandchengyongru 7aaac37bca fix(triggers): require channel enablement predicate 2026-07-19 22:33:29 +08:00
Pei Futongandchengyongru 91514ad0b1 fix(triggers): reject deliveries to disabled channels 2026-07-19 22:33:29 +08:00
chengyongruandGitHub a6b68178aa fix(whatsapp): allow group ids in allowFrom (#4834) 2026-07-19 19:16:37 +08:00
chengyongruandGitHub 2099cb009e fix(providers): fail over across provider failure domains 2026-07-19 17:37:55 +08:00
Pei Futongandchengyongru 39a952ecce fix(cli-apps): decode subprocess output as UTF-8 2026-07-19 16:05:26 +08:00
chengyongruandchengyongru b1232fdaf4 fix(gitstore): preserve staged symlinks 2026-07-19 16:01:11 +08:00
Pei Futongandchengyongru cea8617096 fix(gitstore): resolve staged paths relative to workspace 2026-07-19 16:01:11 +08:00
chengyongruandchengyongru ffb7ddfa1e refactor(triggers): clarify stored integer coercion 2026-07-19 15:46:00 +08:00
santhrealandchengyongru c2071594cf fix(triggers): rename coerce helper to _store_int
Match the cron store-load naming and cover null attempts on deliveries.
2026-07-19 15:46:00 +08:00
santhrealandchengyongru cf96c4d5e9 fix(triggers): coerce null ms fields when loading local triggers
Explicit JSON null for runAtMs/createdAtMs raised TypeError and could
quarantine triggers.json. Treat null/blank like a missing key (0).
2026-07-19 15:46:00 +08:00
chengyongruandchengyongru cf00f537bd fix(agent): guide recovery from oversized tool results
Use the existing in-flight context governor to replace tool output that cannot fit the next model request with a bounded, actionable instruction. The model can retry with narrower arguments, use another tool, or explain the context limit without a second recovery state machine.
2026-07-19 01:08:36 +08:00
cfa49c6e78 fix(render): issue short-lived WebUI tokens via tokenIssueSecret
Map NANOBOT_WEB_TOKEN to channels.websocket.tokenIssueSecret instead of
the static token, and remove the static token. The gateway now issues
short-lived WebSocket/API tokens rather than accepting a long-lived
credential directly at the handshake, matching the public-WebUI login
flow and documentation. Users still enter the same NANOBOT_WEB_TOKEN,
and websocketRequiresToken remains true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:39:59 +08:00
c062e1af14 fix(webui): quiet non-WebSocket handshake noise on public port
The WebSocket channel also serves the WebUI over plain HTTP, so on a
public endpoint (e.g. a Render *.onrender.com service) the underlying
websockets library logs a full-traceback ERROR for every request that
isn't a valid GET handshake: HEAD probes ("unsupported HTTP method;
expected GET"), port scanners, uptime monitors, and TLS-to-plain-port
attempts. These are internet background noise, not server faults.

WebSocketHandshakeNoiseFilter already suppressed "opening handshake
failed" records caused by mid-handshake disconnects; widen it to also
suppress records whose exception chain contains websockets'
InvalidMessage, which covers both non-GET methods and malformed/empty
requests. Genuine server-side handshake errors still log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:39:59 +08:00
Ho1yShifandXubin Ren c77379099b refactor(entrypoint): improve privilege dropping and config initialization
- Updated entrypoint.sh to initialize the on-disk config only if it does not already exist, preserving user edits across restarts.
- Enhanced privilege dropping logic to ensure the container does not run as root if the privilege drop fails.
- Clarified comments in Dockerfile and entrypoint.sh for better understanding of the privilege management process.
- Updated README.md to include a note about persistent disks requiring a paid service on Render.
- Adjusted render.yaml to clarify the Docker command behavior and added a note regarding auto-deploy settings.
2026-07-18 17:39:59 +08:00
Ho1yShifandXubin Ren ca873e4d17 fix(README): update Deploy to Render link to point to the correct GitHub repository 2026-07-18 17:39:59 +08:00
Ho1yShifandXubin Ren 63895fc101 fix: update Deploy to Render link in README
Changed the repository link for the one-click Deploy to Render button in the README.md file to point to the correct GitHub repository.
2026-07-18 17:39:59 +08:00
770d89b430 feat: add one-click Deploy to Render support
Adds a Render Blueprint (render.yaml) and supporting pieces so nanobot can
be deployed to Render in one click, with persistent memory across deploys.

- render.yaml: web service + 1GB persistent disk mounted at
  /home/nanobot/.nanobot. Prompts for ANTHROPIC_API_KEY and
  NANOBOT_WEB_TOKEN at deploy time (sync: false).
- render-config.json: committed gateway config that wires secrets via
  ${VAR} placeholders (resolved at runtime). Nothing secret is committed.
- entrypoint.sh: adds a branch gated on RENDER=true that copies the config
  onto the mounted disk, chowns the root-owned mount, and drops to the
  non-root nanobot user via setpriv. Local (non-Render) path is unchanged.
- Dockerfile: COPY render-config.json; USER nanobot -> USER root so the
  entrypoint can chown the freshly-mounted disk before dropping privileges;
  add PYTHONUNBUFFERED/PYTHONFAULTHANDLER for diagnosable crash output.
- README.md: Deploy to Render button + section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:39:59 +08:00
santhrealandXubin Ren afed32b013 fix(cron): dual-case keys when loading jobs.json
jobs.json hand-edits and asdict-style snake_case for schedule intervals and
runHistory crashed or silently disabled cron. Deserialize via Cron* from_store_dict
and shared get_camel_snake (also used by local triggers).
2026-07-18 17:39:06 +08:00
bingqilinweimaotaiandXubin Ren 8c68c6fe1e feat: support Kimi K3 2026-07-18 17:38:32 +08:00
Yuxin LouandXubin Ren b76d54aae1 Harden default Docker Compose security 2026-07-18 17:37:41 +08:00
KDBandXubin Ren d35f99abfc fix(session): bound the in-memory session cache
Keep only 128 recently used sessions strongly cached while retaining weak references to evicted sessions still owned by active callers. This bounds idle memory growth without allowing duplicate live Session objects or skipping shutdown flushes.

Add LRU, lifecycle, SDK, and flush regression coverage.

Refs #4786
2026-07-18 17:37:07 +08:00
adabarbulescuandXubin Ren d4f5abe004 fix: preserve real cancellation in MCP paths 2026-07-18 17:36:35 +08:00
yu-xin-candXubin Ren 07ad0bafa8 test(exec): relax wait-for timing on Windows 2026-07-18 17:35:56 +08:00
yu-xin-candXubin Ren 995cc44e89 fix(exec): isolate exec session managers 2026-07-18 17:35:56 +08:00
santhrealandXubin Ren 7ac9a46978 fix(utils): avoid hang in split_message when max_len <= 0
When max_len is 0 or negative the cut pointer never advances, so the loop hangs. Return the content unsplit, matching truncate_text_to_tokens for non-positive budgets.
2026-07-18 17:24:12 +08:00
santhrealandXubin Ren fe0e65928d fix(utils): coerce Tavily usage counters to int
JSON APIs sometimes return numeric fields as strings; subtracting them raised TypeError and /status dropped the usage block.
2026-07-18 17:23:26 +08:00
santhrealandXubin Ren 85097aa143 fix(utils): handle empty commit messages in CommitInfo.format
Empty git commit messages made splitlines()[0] raise IndexError in format() and /dream-restore list rendering.
2026-07-18 17:22:16 +08:00
Peter Dave Helloandchengyongru 6de5a0c5ca Improve zh-TW Traditional Chinese locale 2026-07-18 00:59:05 +08:00
bingqilinweimaotaiandchengyongru 8a48af7c74 fix(providers): omit Kimi K2.5/K2.6 temperature 2026-07-17 22:39:33 +08:00
Xubin Ren b4adb29c2b feat(webui): support native folder picker bridges 2026-07-17 12:26:03 +08:00
Xubin Ren 6519737860 docs(readme): reflect community maintenance 2026-07-16 13:52:12 +08:00
chengyongruandchengyongru d4e0294734 fix(gateway): stop channels before draining tasks 2026-07-15 22:25:08 +08:00
chengyongruandchengyongru 681edfa6f3 fix(providers): honor Codex proxy config consistently 2026-07-15 20:01:48 +08:00
chengyongruandGitHub ba86dccc8d fix(webui): correct activity timer duration (#4649) 2026-07-15 16:46:05 +08:00
chengyongruandGitHub 5ed28a6744 fix(webui): validate inferred file paths before preview (#4935) 2026-07-15 10:45:40 +08:00
chengyongruandGitHub aa70aa48f9 fix(cli): point onboarding to the WebUI launcher (#4938) 2026-07-15 10:34:46 +08:00
chengyongruandchengyongru 0fd4d0ab29 fix(prompts): handle undecodable overrides 2026-07-15 01:19:48 +08:00
chengyongruandchengyongru c1fd76add3 refactor(prompts): share workspace override handling 2026-07-15 01:19:48 +08:00
chengyongruandchengyongru 63a6d5d07d fix(heartbeat): retain history after empty runs 2026-07-15 01:19:48 +08:00
Arthur K.andchengyongru dcb37259fa feat(heartbeat): custom evaluator prompt 2026-07-15 01:19:48 +08:00
chengyongruandGitHub 88c38e9b38 fix(restart): deliver completion after channel reconnects (#4931) 2026-07-15 01:08:39 +08:00
chengyongruandGitHub 37165b0db0 feat(webui): highlight slash commands and app mentions (#4933) 2026-07-15 00:34:22 +08:00
chengyongruandchengyongru 2116e32013 test: speed up CI and harden the suite 2026-07-15 00:18:37 +08:00
Olu Bandchengyongru 06f47fa540 fix: standardize --config help text across CLI commands 2026-07-14 23:51:39 +08:00
chengyongruandchengyongru 905da8e34a ci(webui): verify npm and bun lockfiles 2026-07-14 23:16:21 +08:00
5365bab088 fix(webui): sync package-lock.json for qrcode dependency
fe0717b3 ("feat(webui): add guided setup flows") added qrcode and
@types/qrcode to webui/package.json without regenerating the lockfile,
so `npm ci` fails in the Docker build with EUSAGE (Missing: qrcode,
@types/qrcode, dijkstrajs, pngjs, yargs, ...).

Regenerate the lockfile so it matches package.json again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:16:21 +08:00
chengyongruandchengyongru f718c69b2d feat(webui): add copy action to user messages 2026-07-14 22:41:17 +08:00
chengyongruandGitHub 07f54c25e3 chore(codex): identify failing request stage (#4929) 2026-07-14 22:40:10 +08:00
chengyongruandchengyongru 1a1e666625 fix: install timezone data on Windows 2026-07-14 17:30:16 +08:00
chengyongruandchengyongru 297a9e5939 test: cover MCP cleanup cancellation paths 2026-07-14 16:17:35 +08:00
Brian Noahandchengyongru 86f6558707 fix: catch asyncio.CancelledError in close_mcp shutdown
When an MCP server (e.g. stdio browser-agent subprocess) does not
terminate within the AsyncExitStack.aclose() timeout, asyncio raises
CancelledError. The existing exception handler only caught RuntimeError
and BaseExceptionGroup, so CancelledError escaped the except block and
crashed nanobot with exit code 1 on every shutdown.

Add asyncio.CancelledError to the caught exception tuple so the error
is logged at debug level and shutdown completes cleanly.

Stack trace from the crash:

Traceback (most recent call last):
  File ".../nanobot/agent/loop.py", line 1194, in close_mcp
    await stack.aclose()
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../asyncio/__main__.py", line ?, in <module>
  File ".../nanobot/agent/loop.py", line ?, in close_mcp
    ...
RuntimeError: ... (or BaseExceptionGroup) not caught
SystemExit: 1
2026-07-14 16:17:35 +08:00
chengyongruandchengyongru 4916fc07ab fix(agent): close reasoning on stream timeout 2026-07-14 15:51:22 +08:00
WK Wongandchengyongru 11eb9d8cc8 fix(agent): add wall-clock timeout for streaming LLM requests 2026-07-14 15:51:22 +08:00
chengyongruandGitHub 6c9e3a2cc3 refactor(webui): centralize native runtime access (#4769) 2026-07-14 15:00:30 +08:00
chengyongruandGitHub b7048cf76a feat(webui): support document attachments with ingress safeguards (#4771)
* feat: support document attachments in webui

* fix(webui): normalize document attachment MIME

* refactor(webui): move attachment policy out of channel

* fix(webui): reject oversized attachments before send

* fix(webui): align Portuguese attachment errors

* refactor(webui): separate ingress and transport limits

* fix(webui): reject malformed attachment payloads
2026-07-14 14:47:42 +08:00
chengyongruandGitHub b2759e8a6b docs: reorganize documentation around user workflows (#4916) 2026-07-14 13:59:47 +08:00
chengyongruandchengyongru 1643aa7ef5 fix(shell): narrow PowerShell UTF-8 configuration
Avoid changing native pipeline input encoding and consolidate real PowerShell checks to reduce Windows runner startup flakiness.
2026-07-14 13:50:02 +08:00
chengyongruandchengyongru 9f8c2cb1bf test(shell): wait for Windows PowerShell sessions 2026-07-14 13:50:02 +08:00
chengyongruandchengyongru 61afbffc89 fix(shell): configure PowerShell UTF-8 output 2026-07-14 13:50:02 +08:00
adabarbulescuandchengyongru 9cdf17f5d5 Fix Windows exec UTF-16 output decoding 2026-07-14 13:50:02 +08:00
chengyongruandXubin Ren 3b14d59dcd fix(telegram): fall back on overflow HTML rejection 2026-07-14 12:25:28 +08:00
chengyongruandXubin Ren a335ce07db fix(telegram): bound streamed HTML overflow chunks 2026-07-14 12:25:28 +08:00
sanasif786kaandXubin Ren 87478b6e92 fix(telegram): keep raw markdown in overflow stream buffer tail
Send the overflow tail message with HTML parse_mode, but persist the
unrendered markdown chunk in buf.text so later deltas and stream_end
re-render correctly via _split_telegram_markdown_html().
2026-07-14 12:25:28 +08:00
sanasif786kaandXubin Ren 67648774e2 fix(telegram): preserve HTML formatting in stream overflow chunks
Apply parse_mode=HTML to overflow intermediate chunks so markdown
formatting is not lost when long streamed messages are split.

Fixes #4637
2026-07-14 12:25:28 +08:00
535 changed files with 24327 additions and 7342 deletions
+33 -7
View File
@@ -19,14 +19,25 @@ permissions:
jobs:
test:
name: Python (${{ matrix.name }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
python-version: ${{ fromJSON('["3.13","3.14"]') }}
include:
- name: minimum, 3.11
os: ubuntu-latest
python-version: "3.11"
coverage: false
- name: latest, 3.14 + coverage
os: ubuntu-latest
python-version: "3.14"
coverage: true
- name: Windows, 3.14
os: windows-latest
python-version: "3.14"
coverage: false
steps:
- uses: actions/checkout@v4
@@ -47,10 +58,21 @@ jobs:
run: uv sync --all-extras --dev
- name: Lint with ruff
run: uv run ruff check nanobot --select F
if: matrix.coverage
run: uv run ruff check nanobot tests conftest.py
- name: Run tests
run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered
- name: Run tests with coverage
if: matrix.coverage
run: >-
uv run python -m pytest
--cov=nanobot --cov-report=term-missing:skip-covered
--durations=25 --durations-min=1.0
- name: Run compatibility tests
if: ${{ !matrix.coverage }}
run: >-
uv run python -m pytest
--durations=25 --durations-min=1.0
webui:
runs-on: ubuntu-latest
@@ -64,9 +86,13 @@ jobs:
with:
bun-version: 1.3.6
- name: Verify npm lockfile
working-directory: webui
run: npm ci --ignore-scripts --dry-run
- name: Install WebUI dependencies
working-directory: webui
run: bun install
run: bun install --frozen-lockfile
- name: Lint WebUI
working-directory: webui
+1 -1
View File
@@ -36,7 +36,7 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are self-contained packages auto-discovered via `pkgutil` scanning.
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
+15 -1
View File
@@ -28,6 +28,12 @@ COPY nanobot/ nanobot/
COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --system --no-cache ".[$NANOBOT_EXTRAS]"
# Render deploy template (see render.yaml): committed gateway config that wires
# secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved
# at startup). Lives in the code dir (/app), not the data dir, so a mounted disk
# won't shadow it. Only used when RENDER=true; ignored by local runs.
COPY render-config.json ./
# Create non-root user and config directory
RUN useradd -m -u 1000 -s /bin/bash nanobot && \
mkdir -p /home/nanobot/.nanobot && \
@@ -36,8 +42,16 @@ RUN useradd -m -u 1000 -s /bin/bash nanobot && \
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
USER nanobot
# Start as root so the entrypoint can chown the data dir (on Render, the
# freshly-mounted root-owned persistent disk) before dropping to the non-root
# nanobot user via setpriv. The entrypoint drops privileges on every root start
# and fails closed if it cannot, so the agent never runs as root (see
# entrypoint.sh).
USER root
ENV HOME=/home/nanobot
# 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
EXPOSE 18790 8765
+25 -10
View File
@@ -46,6 +46,15 @@
| 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) |
| 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).
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
## What can nanobot do?
@@ -101,13 +110,13 @@ For older updates, see the [release archive](./docs/release-archive.md) or [GitH
## 📦 Install
> [!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`.
Pick **one** install method:
Prerequisites: Python 3.11 or newer. Git is only needed for a source install; Node.js/Bun are only needed if you are developing the WebUI itself.
Prerequisites: Python 3.11 or newer. Git is only needed for a source install. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it.
If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path.
@@ -125,7 +134,7 @@ Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**.
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. The installer also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@@ -165,18 +174,24 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c
**Install from source**
`bun` or `npm` must be available. From an activated virtual environment:
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install -e .
python -m pip install .
```
On Windows, if pip reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install. Contributors who need an editable checkout should follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`webui/README.md`](./webui/README.md).
Verify the install:
```bash
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
**1. Initialize**
@@ -246,13 +261,13 @@ For another provider, the same config shape still applies:
**3. Open the WebUI**
Start the browser workbench:
The stable-compatible path is:
```bash
nanobot webui
nanobot gateway
```
`nanobot webui` prepares the local WebSocket channel if needed, starts the gateway, and opens `http://127.0.0.1:8765`. It binds the first-run WebUI to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot webui --background`, then manage the gateway with `nanobot gateway status`, `logs`, `restart`, and `stop`.
Leave the terminal open and visit `http://127.0.0.1:8765`. Current source versions also provide `nanobot webui`, which prepares the local WebSocket channel if needed, starts the gateway, and opens the browser automatically. The first-run WebUI binds to `127.0.0.1` by default, so it is not exposed to your LAN. Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
For manual or terminal-only setup, test one CLI message:
@@ -292,7 +307,7 @@ The WebUI ships **inside the published wheel** — no extra build step. It is th
nanobot webui
```
The command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
On current source versions, the command enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). If your installed stable release does not include `nanobot webui`, run `nanobot gateway` and open that address manually. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
@@ -366,7 +381,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, review, and contribution gui
## Contact
This project was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and continues to be maintained in an individual capacity using personal resources, with contributions from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
### Contributors
+1 -1
View File
@@ -129,7 +129,7 @@ pip install --upgrade nanobot-ai
**Important Notes:**
- Keep `litellm` updated to the latest version for security fixes
- Run `pip-audit` regularly, including optional channel dependencies such as `nanobot-ai[whatsapp]`
- Run `pip-audit` regularly after enabling the channels used in production; their manifest-declared dependencies are installed into the same environment
- Subscribe to security advisories for nanobot and its dependencies
### 7. Production Deployment
+51
View File
@@ -0,0 +1,51 @@
"""Cross-suite test infrastructure."""
from __future__ import annotations
import os
import ssl
import sys
from collections.abc import Iterator
import certifi
import pytest
@pytest.fixture(scope="session", autouse=True)
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
Loading certifi takes roughly 0.7 seconds per client on Windows. The test
suite constructs hundreds of clients while mocking their I/O. System roots
preserve certificate verification for accidental local requests; explicit
``cafile``, ``capath``, and ``cadata`` arguments still use the real loader.
"""
if sys.platform != "win32":
yield
return
original = ssl.create_default_context
certifi_path = os.path.normcase(os.path.abspath(certifi.where()))
def create_default_context(
purpose: ssl.Purpose = ssl.Purpose.SERVER_AUTH,
*,
cafile: str | None = None,
capath: str | None = None,
cadata: str | bytes | None = None,
) -> ssl.SSLContext:
requested_path = os.path.normcase(os.path.abspath(cafile)) if cafile else None
if requested_path == certifi_path and capath is None and cadata is None:
return original(purpose)
return original(
purpose,
cafile=cafile,
capath=capath,
cadata=cadata,
)
ssl.create_default_context = create_default_context
try:
yield
finally:
ssl.create_default_context = original
+16
View File
@@ -0,0 +1,16 @@
x-bwrap-security: &bwrap-security
cap_add:
- SYS_ADMIN
security_opt:
- apparmor=unconfined
- seccomp=unconfined
services:
nanobot-gateway:
<<: *bwrap-security
nanobot-api:
<<: *bwrap-security
nanobot-cli:
<<: *bwrap-security
-5
View File
@@ -6,11 +6,6 @@ x-common-config: &common-config
- ~/.nanobot:/home/nanobot/.nanobot
cap_drop:
- ALL
cap_add:
- SYS_ADMIN
security_opt:
- apparmor=unconfined
- seccomp=unconfined
services:
nanobot-gateway:
+60 -126
View File
@@ -1,150 +1,84 @@
# nanobot Docs
# nanobot Documentation
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet.
Use these docs to get a working agent first, then open a task guide only when you need the next capability. Source-level design and extension details are kept in the contributor section.
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md), open the browser workbench with `nanobot webui`, and use terminal checks when you need lower-level diagnosis.
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) |
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).
## Start Here
| Goal | Read | Outcome |
| Your situation | Read this | You are done when... |
|---|---|---|
| Start with no technical background | [`start-without-technical-background.md`](./start-without-technical-background.md) | One-command setup, terminal basics, config, API keys, and the first reply |
| Install and get the first reply | [`quick-start.md`](./quick-start.md) | A working CLI agent and a known-good config path |
| 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 |
| 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 |
| 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 |
| Something already failed | [Troubleshooting](./troubleshooting.md) | You have isolated the problem to install, config, model, gateway, channel, or tool access |
## Task Guides
The recommended first-run path is:
Use these pages when you know the workflow you want and do not want to scan the
full reference first.
1. Install nanobot.
2. Choose **Quick Start** in `nanobot onboard --wizard`.
3. Run `nanobot gateway` and open `http://127.0.0.1:8765`.
4. Send `Hello!` before configuring anything else.
Most people do not need to edit JSON for the first run. The wizard handles the initial provider, model, and local WebUI settings. Current source versions also provide `nanobot webui` to start the gateway and open the browser in one step. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
## Add One Capability
Pick the row that matches what you want to accomplish next:
| Goal | Guide |
|---|---|
| Build a personal AI agent | [`guides/build-a-personal-ai-agent.md`](./guides/build-a-personal-ai-agent.md) |
| Run a self-hosted AI agent | [`guides/self-hosted-ai-agent.md`](./guides/self-hosted-ai-agent.md) |
| Use a browser AI agent WebUI | [`guides/ai-agent-webui.md`](./guides/ai-agent-webui.md) |
| Connect an AI agent to chat apps | [`guides/chat-app-ai-agent.md`](./guides/chat-app-ai-agent.md) |
| Run long-running agent tasks | [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) |
| Schedule or trigger agent turns | [`automations.md`](./automations.md) |
| Add long-term agent memory | [`guides/ai-agent-memory.md`](./guides/ai-agent-memory.md) |
| Add MCP tools to an agent | [`guides/mcp-tools-for-ai-agents.md`](./guides/mcp-tools-for-ai-agents.md) |
| Run an agent from Python | [`guides/python-ai-agent-sdk.md`](./guides/python-ai-agent-sdk.md) |
| Expose an OpenAI-compatible agent API | [`guides/openai-compatible-agent-api.md`](./guides/openai-compatible-agent-api.md) |
| Deploy a long-running agent gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
| Learn the browser workbench | [WebUI](./webui.md) |
| Connect Telegram, Discord, Slack, Feishu, WeChat, Email, or another chat app | [Chat Apps](./chat-apps.md) |
| Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
| Generate images | [Image Generation](./image-generation.md) |
| Schedule work or create a local trigger | [Automations](./automations.md) |
| Understand and manage long-term memory | [Memory](./memory.md) |
| Run nanobot continuously | [Deployment](./deployment.md) |
| Run separate bots or workspaces | [Multiple Instances](./multiple-instances.md) |
| Call nanobot from Python | [Python SDK](./python-sdk.md) |
| Expose an OpenAI-compatible endpoint | [OpenAI-Compatible API](./openai-api.md) |
Platform-specific chat guides:
[`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).
For shorter, outcome-focused walkthroughs, browse the [task guide index](./guides/README.md).
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).
## Operate nanobot
## After the First Reply Works
Do not configure everything at once. Pick one next surface:
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`.
| Next goal | Read | First check |
|---|---|---|
| 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 |
| Need | Read |
|---|---|
| Commands and flags | [CLI Reference](./cli-reference.md) |
| In-chat slash commands | [In-Chat Commands](./chat-commands.md) |
| Config, workspace, gateway, sessions, tools, and memory in plain language | [Concepts](./concepts.md) |
| Provider/model matching and selection | [Providers and Models](./providers.md) |
| Setup and runtime diagnosis | [Troubleshooting](./troubleshooting.md) |
| Older development highlights | [Release Archive](./release-archive.md) |
## Reference
| 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 |
Use reference pages to look up an exact option after you know what you are trying to configure:
## Fast Lookup
| Need | Jump to |
| Area | Reference |
|---|---|
| Provider/model resolution order | [`providers.md#provider-resolution`](./providers.md#provider-resolution) |
| Model presets and fallback chains | [`providers.md#model-presets`](./providers.md#model-presets) and [`providers.md#fallback-models`](./providers.md#fallback-models) |
| Langfuse environment variables | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) |
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) |
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) |
| Python SDK usage | [`python-sdk.md`](./python-sdk.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) |
| Every configuration field and default | [Configuration](./configuration.md) |
| Provider and model behavior | [Providers and Models](./providers.md) |
| Chat channel prerequisites and manual JSON | [Chat Apps](./chat-apps.md) |
| WebSocket authentication and wire protocol | [WebSocket](./websocket.md) |
| Python SDK classes, events, sessions, and hooks | [Python SDK](./python-sdk.md) |
| OpenAI-compatible HTTP routes and payloads | [OpenAI-Compatible API](./openai-api.md) |
| Runtime self-inspection and tuning | [My Tool](./my-tool.md) |
## Extend nanobot
Configuration examples are usually snippets to merge into `~/.nanobot/config.json`, not complete replacement files. The docs use camelCase because nanobot writes config that way. Keep real API keys, bot tokens, and passwords out of issues and public logs.
| Goal | Read | Outcome |
|---|---|---|
| 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 |
## Extend or Contribute
## Reading Strategy
These pages explain implementation and extension points. You do not need them to install or operate nanobot.
Use the docs in this order when you are unsure where to go:
| Goal | Read |
|---|---|
| Understand source ownership and runtime flow | [Architecture](./architecture.md) |
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
| Build the WebUI source | [WebUI Development](../webui/README.md) |
1. If terminal commands or config files are new to you, [`start-without-technical-background.md`](./start-without-technical-background.md) explains the setup words and uses one concrete provider example so there is only one decision at a time.
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.
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.
+4 -4
View File
@@ -81,11 +81,11 @@ Main files:
| Area | Files |
|---|---|
| Base channel contract | `nanobot/channels/base.py` |
| Built-in channels | `nanobot/channels/*.py` |
| Channel packages | `nanobot/channels/<channel>/` |
| Discovery and lifecycle | `nanobot/channels/manager.py` |
| WebSocket/WebUI channel | `nanobot/channels/websocket.py` |
| WebSocket/WebUI channel | `nanobot/channels/websocket/` |
Channels are discovered through built-in module scanning and plugin entry points. A custom channel should follow [`channel-plugin-guide.md`](./channel-plugin-guide.md).
Channels are discovered by scanning self-contained packages under `nanobot/channels/`. Add a channel by contributing one package that follows [`channel-package-guide.md`](./channel-package-guide.md).
## WebUI and Gateway
@@ -181,7 +181,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
| Extension | How |
|---|---|
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
| Channel | Implement `BaseChannel`, expose an entry point, follow [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
+792
View File
@@ -0,0 +1,792 @@
# Channel Package Guide
Use this guide to add a self-contained channel package to the nanobot repository. A channel is part of nanobot when its package lives at `nanobot/channels/<channel>/`; there is no separate external channel-plugin path.
> **Breaking change:** nanobot no longer discovers the `nanobot.channels` Python entry-point group. Move an entry-point implementation into `nanobot/channels/<channel>/` with a package-owned manifest, runtime, tests, and optional WebUI contribution.
## How It Works
When `nanobot gateway` starts, nanobot scans the packages under `nanobot/channels/` and loads each dependency-free `ChannelPlugin` descriptor from `manifest.py`.
If a matching config section has `"enabled": true`, the channel is instantiated and started.
## Ownership and Sources of Truth
| Concern | Owner and source of truth |
|---------|---------------------------|
| Runtime behavior and platform SDK use | `runtime.py` and package-local helpers |
| Python package requirements | `ChannelPlugin.dependencies` in `manifest.py` |
| Writable settings fields, types, defaults, requirements, secret handling, and validation | `ChannelPlugin.setup` in `manifest.py` |
| Persisted config expansion, instance updates, and runtime naming | `ChannelPlugin.management` backed by a dependency-free module |
| Interactive setup connections and their short-lived state | `ChannelPlugin.connector` backed by package-local `connect.py` |
| Reusable local login-state detection | `ChannelPlugin.management.local_state_present` backed by package-local code |
| Discovery metadata and lazy runtime target | `PLUGIN` in `manifest.py` |
| WebUI structure, components, URLs, field keys, actions, and preset values | `webui/index.ts` or `webui/index.tsx` |
| Channel-specific user-facing copy | `webui/locales/<locale>.json` |
| Generic settings-shell copy shared by every channel | `webui/src/i18n/locales/<locale>/common.json` |
Keep one source of truth for each concern. In particular, the backend setup contract decides what may be written, the TypeScript contribution decides how those fields are presented, and locale JSON supplies the channel-specific words shown to users.
## Quick Start
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
### Project Structure
```text
nanobot/channels/webhook/
├── __init__.py # lightweight package marker; do not import the runtime
├── manifest.py # dependency-free ChannelPlugin descriptor
├── runtime.py # channel implementation and optional SDK imports
├── tests/ # package-local tests
└── webui/ # optional settings UI and translations
```
### 1. Create Your Channel
```python
# nanobot/channels/webhook/__init__.py
"""Webhook channel package."""
```
```python
# nanobot/channels/webhook/manifest.py
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec
from nanobot.channels.plugin import ChannelPlugin
PLUGIN = ChannelPlugin(
name="webhook",
display_name="Webhook",
runtime=f"{__package__}.runtime:WebhookChannel",
dependencies=("aiohttp>=3.9.0,<4.0.0",),
setup=ChannelSetupSpec(
fields={
"port": ChannelFieldSpec(kind="int", default=9000),
"allowFrom": ChannelFieldSpec(kind="list"),
},
),
)
```
```python
# nanobot/channels/webhook/runtime.py
import asyncio
from typing import Any
from aiohttp import web
from loguru import logger
from pydantic import Field
from nanobot.channels.base import BaseChannel
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
async def start(self) -> None:
"""Start an HTTP server that listens for incoming messages.
IMPORTANT: start() must block forever (or until stop() is called).
If it returns, the channel is considered dead.
"""
self._running = True
port = self.config.port
app = web.Application()
app.router.add_post("/message", self._on_request)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
logger.info("Webhook listening on :{}", port)
# Block until stopped
while self._running:
await asyncio.sleep(1)
await runner.cleanup()
async def stop(self) -> None:
self._running = False
async def send(self, msg: OutboundMessage) -> None:
"""Deliver an outbound message.
msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — channel routing context such as message/thread ids
msg.event — typed runtime event for progress/status messages
"""
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
# In a real plugin: POST to a callback URL, send via SDK, etc.
async def _on_request(self, request: web.Request) -> web.Response:
"""Handle an incoming HTTP POST."""
body = await request.json()
sender = body.get("sender", "unknown")
chat_id = body.get("chat_id", sender)
text = body.get("text", "")
media = body.get("media", []) # list of URLs
# This is the key call: validates allowFrom, then puts the
# message onto the bus for the agent to process.
await self._handle_message(
sender_id=sender,
chat_id=chat_id,
content=text,
media=media,
)
return web.json_response({"ok": True})
```
The package directory, `PLUGIN.name`, runtime class name, and config section must all use `webhook`. Channel names use a portable ASCII package identifier: they start with a letter and contain only letters, digits, or underscores.
Declare runtime requirements directly in `ChannelPlugin.dependencies`. Do not add channel requirements to the root `pyproject.toml`: the package manifest is the source of truth used by the CLI, WebUI, and gateway startup. Keep the manifest and anything it imports free of the optional SDK itself.
### 2. Configure
```bash
nanobot plugins list # verify the channel package appears as "webhook"
nanobot onboard # add default config for detected channels
```
Edit `~/.nanobot/config.json`:
```json
{
"channels": {
"webhook": {
"enabled": true,
"port": 9000,
"allowFrom": ["*"]
}
}
}
```
nanobot always loads the dependency-free descriptor during discovery. When the WebUI gateway starts, it installs missing requirements for enabled channels before importing their runtimes. It also installs them when a channel is enabled from the CLI or WebUI. Status, configuration, and disable operations do not need the runtime. Single-instance and multi-instance channels use the same activation rules.
### 3. Run & Test
```bash
nanobot gateway
```
In another terminal:
```bash
curl -X POST http://localhost:9000/message \
-H "Content-Type: application/json" \
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
```
The agent receives the message and processes it. Replies arrive in your `send()` method.
## Channel Package Requirements
Every channel is a self-contained package at `nanobot/channels/<channel>/`; channel-specific runtime code, setup metadata, tests, WebUI structure, components, and translations stay under that directory.
### Package Layout
```text
nanobot/channels/<channel>/
├── __init__.py # package marker only; no runtime or SDK imports
├── manifest.py # dependency-free ChannelPlugin and ChannelSetupSpec
├── config.py # optional dependency-free config model and defaults
├── connect.py # optional interactive setup connector
├── instances.py # optional dependency-free multi-instance management adapter
├── state.py # optional persisted login-state detection
├── validation.py # optional package-owned setup checks
├── runtime.py # BaseChannel implementation and platform SDK imports
├── tests/ # channel-specific Python tests
└── webui/ # optional, compiled into the shared WebUI
├── index.ts or index.tsx # structure and optional React components
└── locales/
├── en.json # canonical locale shape
└── <locale>.json # one file for every supported WebUI locale
```
Do not add a runtime module directly under `nanobot/channels/`, create a parallel manifest tree, or add a central per-channel UI catalog. If existing channel files move, use `git mv` so history remains traceable.
### Manifest and Runtime Boundary
`manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker.
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, optional dependency extra, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection.
Use the small constructors in [`nanobot/channels/_manifest.py`](../nanobot/channels/_manifest.py) for declarative field and requirement definitions. Use [`nanobot/channels/dingtalk/manifest.py`](../nanobot/channels/dingtalk/manifest.py) as a compact single-instance example and [`nanobot/channels/feishu/`](../nanobot/channels/feishu/) as a multi-instance example.
### Package-owned WebUI
Set `webui="webui/index.ts"` or `webui="webui/index.tsx"` in the channel manifest. Candidate modules are bundled from channel packages, but the settings UI activates only the exact path returned by the backend feature payload.
The entry module exports one default `ChannelUiContribution`. Channel identity comes from the package directory, so do not repeat a `channel` field in TypeScript. Keep only structure and executable UI data in this module: presentation metadata, icons or logo URLs, docs URLs, config field keys, action payloads, preset values, aliases, and optional `Panel` or `ConnectFlow` components.
Do not put static descriptions, setup steps, labels, placeholders, help text, action labels, or preset labels in TSX. Those strings belong in the channel's locale JSON. TSX remains appropriate for dynamic rendering, interpolation, conditions, and rich component composition.
### Channel-owned i18n
Create `webui/locales/<locale>.json` for every locale code declared in [`webui/src/i18n/config.ts`](../webui/src/i18n/config.ts). Treat `en.json` as the canonical shape; every other locale must contain the same message keys and the same interpolation variables. `displayName` may be omitted when the product name should remain unchanged.
```json
{
"description": "Use nanobot from Example chats.",
"requirements": "Example app credentials and gateway",
"setup": {
"docsLabel": "Open Example setup",
"officialLabel": "Open Example console",
"summary": "Example needs app credentials.",
"tryIt": "Send a test message.",
"steps": [
"Create an Example app.",
"Add the credentials.",
"Save, enable, and test the channel."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Example client ID",
"help": "Copy it from the Example console."
}
},
"actions": {
"copyManifest": "Copy manifest"
},
"presets": {
"default": "Default"
}
},
"custom": {
"connected": "{{name}} is connected."
}
}
```
Field messages are keyed by the config path after `channels.<channel>.`, with remaining punctuation converted to underscores. For example, `channels.signal.dm.allowFrom` maps to `setup.fields.dm_allowFrom`. Action and preset messages use the IDs declared in the TypeScript contribution.
Custom channel components should read dynamic copy with `channelTranslator(t, "<channel>")`; keep the English fallback adjacent to the call so an incomplete translation still renders useful text. Aliases reuse the owning channel's locale namespace rather than duplicating translations.
The dependency direction is intentional:
- [`webui/src/i18n/index.ts`](../webui/src/i18n/index.ts) imports the pure JSON [`channel-plugins/locale-registry.ts`](../webui/src/channel-plugins/locale-registry.ts).
- The locale registry discovers only `nanobot/channels/*/webui/locales/*.json` and must not import the UI registry, React, or TSX.
- Settings components may consume both the UI registry and locale registry.
- Channel UI code may use shared types and generic settings components, but core settings code must not add `if (feature.name === "...")` branches for individual channels.
This separation prevents i18n initialization from eagerly loading every channel React component and keeps channel-specific ownership below the channel package.
### Tests and Definition of Done
Put channel-specific Python tests in `nanobot/channels/<channel>/tests/`. Keep only shared registry, manager, base-class, and cross-channel contract tests in `tests/channels/`. Release builds exclude package-local tests while the repository test configuration discovers both trees.
For a focused channel change, run the smallest relevant set:
```bash
uv run pytest nanobot/channels/<channel>/tests -q
cd webui
bun run test -- src/tests/channel-locale-registry.test.ts src/tests/channel-ui-registry.test.ts src/tests/channel-identity.test.ts
bun run lint
bun run build
```
Before considering the change complete, verify all of the following:
- The manifest can be discovered without importing the runtime or optional platform SDK.
- `ChannelSetupSpec` contains every writable field and rejects unknown fields.
- The TypeScript field, action, and preset IDs have matching English locale messages.
- Every supported locale matches the English key shape and interpolation variables.
- Generic settings copy remains in core `common.json`; channel-specific copy remains inside the channel package.
- User-facing WebUI changes work through the built frontend served by a real gateway, including language switching and refresh persistence.
- Markdown prose paragraphs and individual list items remain on one source line; let the renderer handle visual wrapping.
## BaseChannel API
### Required (abstract)
| Method | Description |
|--------|-------------|
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. Raise when the transport does not accept it. |
#### Outbound delivery contract
A normal return from `send()` means either the visible payload was accepted by the platform transport/API, or the channel deliberately had nothing to deliver, such as an empty progress event. Do not log and return when the client is disconnected, still starting, or the platform rejects the request. Raise an exception so `ChannelManager` can apply the shared retry policy.
`send()` may run as soon as `is_running` becomes true. If a channel sets `_running` before its transport is ready, it must keep raising until delivery can be attempted safely. Small platform-specific retries are fine, but the final failure must still reach the manager.
### Interactive Login
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
```python
async def login(self, force: bool = False) -> bool:
"""
Perform channel-specific interactive login.
Args:
force: If True, ignore existing credentials and re-authenticate.
Returns True if already authenticated or login succeeds.
"""
# For QR-code-based login:
# 1. If force, clear saved credentials
# 2. Check if already authenticated (load from disk/state)
# 3. If not, show QR code and poll for confirmation
# 4. Save token on success
```
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
Users trigger interactive login via:
```bash
nanobot channels login <channel_name>
nanobot channels login <channel_name> --force # re-authenticate
```
### Provided by Base
| Method / Property | Description |
|-------------------|-------------|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
| `default_config()` (classmethod) | Returns runtime-local defaults for callers that construct the class directly. Discovery and onboarding use the descriptor instead. |
| `refresh_feature_metadata(config_path, instance_id)` (classmethod) | Optionally refreshes saved display metadata after an explicit settings action. It is never called by a read-only feature GET. |
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. |
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional management contract
Persisted-state management belongs to `ChannelPlugin.management`, not `BaseChannel`. Keep the adapter and anything it imports free of optional platform SDKs so status, settings, and disable operations still work when the runtime cannot be imported. Runtime classes own network lifecycle, message delivery, interactive login, enable-time availability checks, and explicit runtime-only actions such as metadata refresh.
```python
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec, SetupRequirement
from nanobot.channels.plugin import ChannelPlugin
from .instances import MANAGEMENT
PLUGIN = ChannelPlugin(
name="webhook",
display_name="Webhook",
runtime=f"{__package__}.channel:WebhookChannel",
setup=ChannelSetupSpec(
fields={
"token": ChannelFieldSpec(kind="secret"),
"region": ChannelFieldSpec(
kind="enum",
choices=frozenset({"us", "eu"}),
default="us",
),
},
required=(SetupRequirement.field("token"),),
),
management=MANAGEMENT,
)
```
`instances.py` then exports the dependency-free adapter assembled from channel-owned callbacks:
```python
from typing import Any
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
from .config import default_config
def instance_specs(section: Any, *, enabled_only: bool = True) -> list[ChannelInstanceSpec]:
... # Expand the persisted channel-owned envelope.
def update_instance_config(
section: Any,
values: dict[str, Any],
*,
instance_id: str = "default",
) -> dict[str, Any]:
... # Update one instance without discarding sibling data.
MANAGEMENT = ChannelManagementSpec(
multi_instance=True,
default_config=default_config,
instance_specs=instance_specs,
update_instance_config=update_instance_config,
)
```
`ChannelSetupSpec` is authoritative for writable field names, field types, choices, defaults, required setup, secret redaction, and optional backend validation. The settings API rejects fields outside this contract. A validator receives `(values, context)`; use `context.allow_local_service_access` for host network policy instead of loading global config from the channel package.
The dependency-free `MANAGEMENT` value is a `ChannelManagementSpec`. Multi-instance plugins provide `instance_specs(section, enabled_only=True)` and `update_instance_config(section, values, instance_id=...)`; they may also provide `default_config`, `runtime_name`, presentation-only `feature_instances`, and `local_state_present`. Single-instance plugins normally derive onboarding defaults from `ChannelSetupSpec`; use `default_config` only when persisted defaults include fields that are not part of generic setup.
Multi-instance adapters return `ChannelInstanceSpec` objects and preserve their persisted envelope when updating one instance. Their descriptor sets `ChannelManagementSpec(multi_instance=True)`. The shared contract enforces these invariants:
- every `instance_id` is non-empty and unique;
- the management adapter's `runtime_name(channel_name, instance_id)` is the single source of routing names, and every derived name is unique and is either the channel name or starts with `<channel-name>.`;
- runtime names cannot overwrite a runtime already owned by another channel;
- settings instance summaries are generated from `instance_specs()` and `ChannelPlugin.setup`. They contain the authoritative `enabled` and `configured` state plus secret-safe `config_values` and `configured_fields` for the generic instance editor;
- the management adapter's `feature_instances()` may return `None` or presentation overrides containing an `id` plus `name`, `display_name`, or `avatar_url`. It cannot override runtime state or the configuration snapshot.
`ChannelInstanceSpec` contains only `instance_id` and the instance config; nanobot derives its runtime name through the adapter. Single-instance plugins keep ownership of their entire config, including a field named `instances`. Only plugins whose management spec sets `multi_instance=True` opt into instance expansion.
The package/config section name owns every runtime produced from that section. Class inheritance does not transfer runtime ownership to another package.
Return a concrete iterable or generator from the adapter's `instance_specs()`; nanobot materializes and validates it before constructing any runtime. Raise an exception for malformed persisted data rather than silently changing instance identity. Keep network-backed metadata refresh behind the runtime's `refresh_feature_metadata()` so feature GET requests remain dependency-free and read-only.
For package layout, WebUI ownership, and localization rules, see [Channel Package Requirements](#channel-package-requirements).
### Optional (streaming)
| Method | Description |
|--------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
### Message Types
```python
@dataclass
class OutboundMessage:
channel: str # your channel name
chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # channel routing context, e.g. "message_id" for threading
event: object | None # typed runtime/UI event; usually inspect with isinstance()
```
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
## Streaming Support
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
### How It Works
When **both** conditions are met, the agent streams content through your channel:
1. Config has `"streaming": true`
2. Your subclass overrides `send_delta()`
If either is missing, the agent falls back to the normal one-shot `send()` path.
### Implementing `send_delta`
Override `send_delta` to handle two types of calls:
```python
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
# Streaming finished — do final formatting, cleanup, etc.
return
# Regular delta — append text, update the message on screen
# delta contains a small chunk of text (a few tokens)
```
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
### Example: Webhook with Streaming
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._buffers: dict[str, str] = {}
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
text = self._buffers.pop(buffer_key, "")
# Final delivery — format and send the complete message
await self._deliver(chat_id, text, final=True)
return
self._buffers.setdefault(buffer_key, "")
self._buffers[buffer_key] += delta
# Incremental update — push partial text to the client
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
async def send(self, msg: OutboundMessage) -> None:
# Non-streaming path — unchanged
await self._deliver(msg.chat_id, msg.content, final=True)
```
### Config
Enable streaming per channel:
```json
{
"channels": {
"webhook": {
"enabled": true,
"streaming": true,
"allowFrom": ["*"]
}
}
}
```
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
### BaseChannel Streaming API
| Method / Property | Description |
|-------------------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
## Progress, Tool Hints, and Reasoning
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
### Progress and Tool Hints
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
```python
from nanobot.bus.outbound_events import ProgressEvent
async def send(self, msg: OutboundMessage) -> None:
event = msg.event
if isinstance(event, ProgressEvent) and event.tool_hint:
# A short tool breadcrumb, e.g. read_file("config.json")
await self._send_trace(msg.chat_id, msg.content, kind="tool")
return
if isinstance(event, ProgressEvent):
# Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress")
return
await self._send_message(msg.chat_id, msg.content, media=msg.media)
```
Tool hints are off by default for most channels. Users can enable them globally or per channel:
```json
{
"channels": {
"sendToolHints": true,
"webhook": {
"enabled": true,
"sendToolHints": true
}
}
}
```
### Reasoning Blocks
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._reasoning_buffers: dict[str, str] = {}
async def send_reasoning_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
text = self._reasoning_buffers.pop(buffer_key, "")
if text:
await self._update_reasoning_block(chat_id, text, final=True)
```
**Reasoning arguments:**
| Argument | Meaning |
|------|---------|
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
| `send_reasoning_end()` | The current reasoning block is complete. |
Reasoning visibility is controlled by `showReasoning` globally or per channel:
```json
{
"channels": {
"showReasoning": true,
"webhook": {
"enabled": true,
"showReasoning": true
}
}
}
```
Recommended rendering:
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
## Config
### Why Pydantic model is required
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`**`dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
Channel runtimes use Pydantic config models by subclassing `Base` from `nanobot.config.schema`.
### Pattern
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
```python
from pydantic import Field
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
```
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
2. Convert `dict` → model in `__init__`:
```python
from typing import Any
from nanobot.bus.queue import MessageBus
class WebhookChannel(BaseChannel):
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
```
3. Access config as attributes (not `.get()`):
```python
async def start(self) -> None:
port = self.config.port
token = self.config.token
```
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
`nanobot onboard` reads the descriptor without importing the runtime. Put writable defaults in `ChannelSetupSpec`:
```python
setup=ChannelSetupSpec(
fields={
"port": ChannelFieldSpec(kind="int", default=9000),
"allowFrom": ChannelFieldSpec(kind="list"),
},
)
```
String and secret fields default to `""`, list fields to `[]`, and boolean fields to `false` when no explicit default is declared. For non-setup or multi-instance persisted defaults, provide `ChannelManagementSpec.default_config` from a dependency-free package-local module.
## Naming Convention
| What | Format | Example |
|------|--------|---------|
| Package directory | `nanobot/channels/{name}` | `nanobot/channels/webhook` |
| Manifest name | `{name}` | `webhook` |
| Config section | `channels.{name}` | `channels.webhook` |
| Runtime import | `nanobot.channels.{name}.runtime` | `nanobot.channels.webhook.runtime` |
## Local Development
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install -e .
nanobot plugins list # should show the package as "webhook"
nanobot gateway # test end-to-end
```
## Verify
```bash
$ nanobot plugins list
Name Type Enabled
discord channel no
telegram channel yes
webhook channel yes
```
-568
View File
@@ -1,568 +0,0 @@
# Channel Plugin Guide
Build a custom nanobot channel in three steps: subclass, package, install.
> **Note:** We recommend developing channel plugins against a source checkout of nanobot (`python -m pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
## How It Works
nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
1. Built-in channels in `nanobot/channels/`
2. External packages registered under the `nanobot.channels` entry point group
If a matching config section has `"enabled": true`, the channel is instantiated and started.
## Quick Start
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
### Project Structure
```text
nanobot-channel-webhook/
├── nanobot_channel_webhook/
│ ├── __init__.py # re-export WebhookChannel
│ └── channel.py # channel implementation
└── pyproject.toml
```
### 1. Create Your Channel
```python
# nanobot_channel_webhook/__init__.py
from nanobot_channel_webhook.channel import WebhookChannel
__all__ = ["WebhookChannel"]
```
```python
# nanobot_channel_webhook/channel.py
import asyncio
from typing import Any
from aiohttp import web
from loguru import logger
from pydantic import Field
from nanobot.channels.base import BaseChannel
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
async def start(self) -> None:
"""Start an HTTP server that listens for incoming messages.
IMPORTANT: start() must block forever (or until stop() is called).
If it returns, the channel is considered dead.
"""
self._running = True
port = self.config.port
app = web.Application()
app.router.add_post("/message", self._on_request)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
logger.info("Webhook listening on :{}", port)
# Block until stopped
while self._running:
await asyncio.sleep(1)
await runner.cleanup()
async def stop(self) -> None:
self._running = False
async def send(self, msg: OutboundMessage) -> None:
"""Deliver an outbound message.
msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — channel routing context such as message/thread ids
msg.event — typed runtime event for progress/status messages
"""
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
# In a real plugin: POST to a callback URL, send via SDK, etc.
async def _on_request(self, request: web.Request) -> web.Response:
"""Handle an incoming HTTP POST."""
body = await request.json()
sender = body.get("sender", "unknown")
chat_id = body.get("chat_id", sender)
text = body.get("text", "")
media = body.get("media", []) # list of URLs
# This is the key call: validates allowFrom, then puts the
# message onto the bus for the agent to process.
await self._handle_message(
sender_id=sender,
chat_id=chat_id,
content=text,
media=media,
)
return web.json_response({"ok": True})
```
### 2. Register the Entry Point
```toml
# pyproject.toml
[project]
name = "nanobot-channel-webhook"
version = "0.1.0"
dependencies = ["nanobot-ai", "aiohttp"]
[project.entry-points."nanobot.channels"]
webhook = "nanobot_channel_webhook:WebhookChannel"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["nanobot_channel_webhook"]
```
The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
### 3. Install & Configure
```bash
python -m pip install -e .
nanobot plugins list # verify the installed example plugin appears as "webhook"
nanobot onboard # auto-adds default config for detected plugins
```
Edit `~/.nanobot/config.json`:
```json
{
"channels": {
"webhook": {
"enabled": true,
"port": 9000,
"allowFrom": ["*"]
}
}
}
```
### 4. Run & Test
```bash
nanobot gateway
```
In another terminal:
```bash
curl -X POST http://localhost:9000/message \
-H "Content-Type: application/json" \
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
```
The agent receives the message and processes it. Replies arrive in your `send()` method.
## BaseChannel API
### Required (abstract)
| Method | Description |
|--------|-------------|
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
### Interactive Login
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
```python
async def login(self, force: bool = False) -> bool:
"""
Perform channel-specific interactive login.
Args:
force: If True, ignore existing credentials and re-authenticate.
Returns True if already authenticated or login succeeds.
"""
# For QR-code-based login:
# 1. If force, clear saved credentials
# 2. Check if already authenticated (load from disk/state)
# 3. If not, show QR code and poll for confirmation
# 4. Save token on success
```
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
Users trigger interactive login via:
```bash
nanobot channels login <channel_name>
nanobot channels login <channel_name> --force # re-authenticate
```
### Provided by Base
| Method / Property | Description |
|-------------------|-------------|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. |
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional (streaming)
| Method | Description |
|--------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
### Message Types
```python
@dataclass
class OutboundMessage:
channel: str # your channel name
chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # channel routing context, e.g. "message_id" for threading
event: object | None # typed runtime/UI event; usually inspect with isinstance()
```
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
## Streaming Support
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
### How It Works
When **both** conditions are met, the agent streams content through your channel:
1. Config has `"streaming": true`
2. Your subclass overrides `send_delta()`
If either is missing, the agent falls back to the normal one-shot `send()` path.
### Implementing `send_delta`
Override `send_delta` to handle two types of calls:
```python
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
# Streaming finished — do final formatting, cleanup, etc.
return
# Regular delta — append text, update the message on screen
# delta contains a small chunk of text (a few tokens)
```
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
### Example: Webhook with Streaming
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._buffers: dict[str, str] = {}
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
text = self._buffers.pop(buffer_key, "")
# Final delivery — format and send the complete message
await self._deliver(chat_id, text, final=True)
return
self._buffers.setdefault(buffer_key, "")
self._buffers[buffer_key] += delta
# Incremental update — push partial text to the client
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
async def send(self, msg: OutboundMessage) -> None:
# Non-streaming path — unchanged
await self._deliver(msg.chat_id, msg.content, final=True)
```
### Config
Enable streaming per channel:
```json
{
"channels": {
"webhook": {
"enabled": true,
"streaming": true,
"allowFrom": ["*"]
}
}
}
```
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
### BaseChannel Streaming API
| Method / Property | Description |
|-------------------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
## Progress, Tool Hints, and Reasoning
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
### Progress and Tool Hints
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
```python
from nanobot.bus.outbound_events import ProgressEvent
async def send(self, msg: OutboundMessage) -> None:
event = msg.event
if isinstance(event, ProgressEvent) and event.tool_hint:
# A short tool breadcrumb, e.g. read_file("config.json")
await self._send_trace(msg.chat_id, msg.content, kind="tool")
return
if isinstance(event, ProgressEvent):
# Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress")
return
await self._send_message(msg.chat_id, msg.content, media=msg.media)
```
Tool hints are off by default for most channels. Users can enable them globally or per channel:
```json
{
"channels": {
"sendToolHints": true,
"webhook": {
"enabled": true,
"sendToolHints": true
}
}
}
```
### Reasoning Blocks
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._reasoning_buffers: dict[str, str] = {}
async def send_reasoning_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
text = self._reasoning_buffers.pop(buffer_key, "")
if text:
await self._update_reasoning_block(chat_id, text, final=True)
```
**Reasoning arguments:**
| Argument | Meaning |
|------|---------|
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
| `send_reasoning_end()` | The current reasoning block is complete. |
Reasoning visibility is controlled by `showReasoning` globally or per channel:
```json
{
"channels": {
"showReasoning": true,
"webhook": {
"enabled": true,
"showReasoning": true
}
}
}
```
Recommended rendering:
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
## Config
### Why Pydantic model is required
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`**`dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
### Pattern
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
```python
from pydantic import Field
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
```
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
2. Convert `dict` → model in `__init__`:
```python
from typing import Any
from nanobot.bus.queue import MessageBus
class WebhookChannel(BaseChannel):
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
```
3. Access config as attributes (not `.get()`):
```python
async def start(self) -> None:
port = self.config.port
token = self.config.token
```
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
```python
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
```
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
If not overridden, the base class returns `{"enabled": false}`.
## Naming Convention
| What | Format | Example |
|------|--------|---------|
| PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
| Entry point key | `{name}` | `webhook` |
| Config section | `channels.{name}` | `channels.webhook` |
| Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
## Local Development
```bash
git clone https://github.com/you/nanobot-channel-webhook
cd nanobot-channel-webhook
python -m pip install -e .
nanobot plugins list # should show the installed example plugin as "webhook"
nanobot gateway # test end-to-end
```
## Verify
```bash
$ nanobot plugins list
Name Type Enabled
discord channel no
telegram channel yes
webhook channel yes
```
+25 -7
View File
@@ -16,7 +16,7 @@ a focused setup path for one platform, start with a guide:
| Email | [Build an Email AI Agent with nanobot](./guides/email-ai-agent.md) |
| Mattermost | [Build a Mattermost AI Agent with nanobot](./guides/mattermost-ai-agent.md) |
Want to build your own channel? See the [Channel Plugin Guide](./channel-plugin-guide.md).
Want to build your own channel? See the [Channel Package Guide](./channel-package-guide.md).
Before configuring a chat app, make sure the local CLI path works:
@@ -26,11 +26,23 @@ nanobot agent -m "Hello!"
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
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.
## Recommended Setup in the WebUI
For normal local setup, let the WebUI write and validate the channel config:
1. Run `nanobot webui`.
2. Open **Settings → Channels**.
3. Search for the platform and open its setup panel.
4. Follow the credential fields or QR flow. The screen tells you which platform-side token, permission, account, or URL it needs.
5. Let nanobot install the optional channel support when prompted.
6. Restart from the WebUI if it reports that a restart is required.
7. Send a private test message. If the channel returns a pairing code, approve the pending request in the WebUI and send the message again.
If your installed stable release does not show **Settings → Channels**, continue with the [manual setup pattern](#manual-setup-pattern) below or install current source.
Optional package installation is available to a same-machine WebUI by default. Remote browser clients cannot change the Python environment unless an administrator explicitly enables that capability. Run `nanobot plugins enable <channel>` locally when the guided install is unavailable.
The sections below explain what each chat platform requires and provide manual config for deployments that manage `config.json` directly.
> [!NOTE]
> If you are upgrading from a version where chat app SDKs were installed by default,
@@ -47,7 +59,9 @@ codes.
> nanobot keeps the saved settings, but stops loading that channel after the
> next restart.
## Common Setup Pattern
## Manual Setup Pattern
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a snippet includes `allowFrom`, it is showing a static allowlist. For pairing-based access on supported channels, omit `allowFrom`; Slack and Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing codes.
Every chat app uses the same shape:
@@ -379,6 +393,10 @@ nanobot channels login whatsapp
}
```
For groups, `allowFrom` can contain either a participant sender ID/LID or a
group JID/bare group ID. A participant entry allows that sender wherever the bot
can see them; a group entry allows replies in that group.
Optional session database path:
```json
+2 -2
View File
@@ -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 |
| Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode |
| OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` |
| WebUI | `nanobot gateway` plus WebSocket channel | Browser workbench served by the WebSocket channel on port `8765` |
| WebUI | `nanobot webui` | Prepare the local WebUI, start the gateway, and open the browser workbench |
The gateway health endpoint is on `gateway.port` (`18790` by default). The browser WebUI is served by the WebSocket channel (`8765` by default), not by the health endpoint.
The WebUI launcher is the normal browser entry point. Underneath, the gateway keeps the WebSocket channel and other long-running services alive. The gateway health endpoint is on `gateway.port` (`18790` by default); the browser WebUI is served on `8765` by default, not by the health endpoint.
## Provider and Model Selection
+11 -5
View File
@@ -4,6 +4,8 @@ Config file: `~/.nanobot/config.json`
This is the full reference. If this is your first install, start with [`quick-start.md`](./quick-start.md). If you are trying to choose a model or fix provider/model matching, use [`providers.md`](./providers.md) first and come back here for exact fields and advanced options.
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 generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`. snake_case keys are also accepted for compatibility, but the docs prefer camelCase because that is what nanobot writes back to disk.
@@ -11,7 +13,7 @@ The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`
For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once.
> [!NOTE]
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
> If your config file is older than the current schema, run `nanobot onboard --refresh`. nanobot adds missing default fields while preserving your existing values.
## Configuration Guides
@@ -47,9 +49,9 @@ the focused guides first and come back here for exact fields and defaults.
| 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) |
## Where to Edit First
## Where a Setting Lives
If you are not sure where a setting belongs, start from the task you are trying to complete. Most changes touch one config section and one verification command.
If the WebUI does not expose the option you need, start from the task below. Most advanced changes touch one config section and one verification command.
| Task | First keys to check | Verify with | Deep dive |
|---|---|---|---|
@@ -185,7 +187,7 @@ These variables are process-level switches. Set them in the same terminal, servi
| 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_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds, around ordinary LLM requests. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
| `NANOBOT_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. |
@@ -1917,7 +1919,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation).
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. The default `docker-compose.yml` drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable `"tools.exec.sandbox": "bwrap"` inside Docker, start Compose with `docker-compose.bwrap.yml` as an additional override so bubblewrap can create nested namespaces.
## Pairing
@@ -2029,6 +2031,10 @@ The heartbeat job is backed by the same cron service as user-created reminders.
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
| `gateway.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
+25 -6
View File
@@ -74,6 +74,20 @@ docker compose logs -f nanobot-gateway # view logs
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
```bash
@@ -87,12 +101,17 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
vim ~/.nanobot/config.json
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
# Mirrors the security caps and port mappings declared in docker-compose.yml:
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`.
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health
# endpoint on 18790.
# `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway
# health endpoint on 18790.
docker run \
--cap-drop ALL \
-v ~/.nanobot:/home/nanobot/.nanobot \
-p 18790:18790 -p 8765:8765 \
nanobot gateway
# If `tools.exec.sandbox: "bwrap"` is enabled, run with the extra permissions
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
# `clone3: Operation not permitted`.
docker run \
--cap-drop ALL --cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \
+15 -11
View File
@@ -1,21 +1,20 @@
# nanobot Guides
# nanobot Task Guides
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 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.
## Build and operate
## Start and Use
| Goal | Guide |
|---|---|
| Build a personal AI agent | [Build a personal AI agent](./build-a-personal-ai-agent.md) |
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
| 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) |
| 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) |
| Add long-term memory | [AI agent memory](./ai-agent-memory.md) |
## Connect and integrate
## Connect a Chat App
Use **Settings → Channels** in the WebUI for guided setup. These guides explain the account, bot, token, permission, and test-message steps on each platform.
| Goal | Guide |
|---|---|
@@ -29,10 +28,15 @@ edge cases.
| Connect QQ | [QQ AI agent](./qq-ai-agent.md) |
| Connect Email | [Email AI agent](./email-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) |
| Expose `/v1/chat/completions` | [OpenAI-compatible agent API](./openai-compatible-agent-api.md) |
## Configure
## Configure and Operate
| Goal | Guide |
|---|---|
+16 -14
View File
@@ -21,10 +21,10 @@ private DMs, team channels, group chats, email threads, or bot workspaces.
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
nanobot webui
```
Then choose one platform guide:
Send `Hello!` in the WebUI before adding a channel. Then choose one platform guide for the bot/account prerequisites:
- [Telegram AI agent](./telegram-ai-agent.md)
- [Discord AI agent](./discord-ai-agent.md)
@@ -38,28 +38,31 @@ Then choose one platform guide:
## Minimal working example
Every channel follows the same pattern:
Use the guided channel setup:
1. Get the platform token, login state, webhook, or mailbox credentials.
2. Merge the channel snippet into `~/.nanobot/config.json`.
3. Prefer pairing for DM-capable channels: omit `allowFrom`, then approve the
first DM's pairing code.
4. For channels without pairing, such as Email, keep access narrow with
`allowFrom` or platform-specific allow lists.
5. Check status:
2. Open **Settings → Channels** in the WebUI.
3. Choose the platform and open its setup panel.
4. Complete the credential or QR flow and install optional support if prompted.
5. Restart when the WebUI requests it.
6. Send a private test message.
7. Approve the pairing request in the WebUI when a DM-capable channel asks for one.
If your installed release does not show **Settings → Channels**, use the full [Chat Apps reference](../chat-apps.md#manual-setup-pattern) to configure the channel manually.
Check status from the terminal when you need a lower-level confirmation:
```bash
nanobot channels status
```
6. Start the gateway:
The `nanobot webui` command already runs the gateway. For a chat-only or server deployment, start it directly:
```bash
nanobot gateway
```
7. Send a test DM, approve the pairing code when prompted, then send the test
message again.
Use the full [Chat Apps reference](../chat-apps.md) when you manage `config.json` directly or need platform-specific advanced settings.
## Production notes
@@ -80,8 +83,7 @@ nanobot gateway
- If `nanobot channels status` does not show the channel, the config key or
optional dependency is likely missing.
- If the first DM returns a pairing code, approve it with
`/pairing approve <code>` before expecting normal replies.
- 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 messages do not arrive, run `nanobot gateway --verbose` and compare
platform credentials, event permissions, and allow lists.
- If group replies are unexpected, review that channel's group policy.
+10 -2
View File
@@ -6,7 +6,7 @@ through the Model Context Protocol.
## What you will build
- a working nanobot agent
- one MCP server entry in `~/.nanobot/config.json`
- one MCP integration configured through Apps or `~/.nanobot/config.json`
- a restricted set of MCP tools exposed to the model
## When to use this
@@ -27,7 +27,15 @@ remote HTTP endpoint.
## Minimal working example
Add this to `~/.nanobot/config.json`:
For local interactive setup:
1. Run `nanobot webui` and open **Apps**.
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
3. Limit the enabled tools when the server exposes more than the task needs.
4. Save and restart when prompted.
5. Mention the integration with `@` in the next message and ask for a small test action.
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
```json
{
+10 -2
View File
@@ -7,7 +7,7 @@ providers.
## What you will build
- web tools enabled in nanobot
- one search provider selected in `config.json`
- one search provider selected in the WebUI or `config.json`
- optional web fetch settings for page reading
## When to use this
@@ -28,7 +28,15 @@ provider, API key, proxy, fetch behavior, or SSRF allowlist.
## Minimal working example
Use the default search provider:
For local interactive setup:
1. Run `nanobot webui`.
2. Open **Settings → Web**.
3. Enable web search, choose a provider, and enter its API key if required.
4. Save and restart when prompted.
5. Ask a question that requires current information and inspect the cited sources.
For manual or deployment-managed config, use the default search provider:
```json
{
+10 -1
View File
@@ -2,10 +2,19 @@
nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway.
The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, save, and restart when prompted. If that screen is not available in your installed version, use the manual config below.
## Quick Setup
**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.
```json
+2
View File
@@ -2,6 +2,8 @@
Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md).
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:
1. Which provider owns the credential or endpoint?
+170 -270
View File
@@ -1,153 +1,197 @@
# Install and Quick Start
This page gets one local nanobot reply working. After that, you can add the WebUI, chat apps, local models, web search, MCP, deployment, or custom plugins.
This guide has one goal: get a normal nanobot reply in your browser. Do not add chat apps, MCP servers, fallback models, or deployment until this path works.
If you have never used a terminal or edited a config file before, use [`start-without-technical-background.md`](./start-without-technical-background.md) first. This page assumes you are comfortable pasting commands and editing JSON snippets.
If terminals, Python, or API keys are unfamiliar, use the [beginner walkthrough](./start-without-technical-background.md), which explains each term and screen.
## Before You Start
These repository docs follow current `main`. The recommended installer uses the stable package, so a newly documented WebUI screen may not appear until the next release. Each advanced guide also provides a CLI or manual config path.
You need:
## What You Need
- Python 3.11 or newer.
- 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.
- Git only if you install from source.
- Node.js or Bun only if you are developing the WebUI itself.
- Access to one supported AI provider, company endpoint, or local model server.
- The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key.
> [!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.
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.
## 1. Install
## 1. Install nanobot
Pick one install method.
The recommended installer keeps nanobot out of the system Python environment and opens the setup wizard when installation finishes.
**One-command setup:**
**macOS / Linux**
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
```
On Windows PowerShell:
**Windows PowerShell**
```powershell
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes, go straight to [Open the WebUI](#5-open-the-webui).
The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release unless you explicitly pass `--dev`. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run
```
## 2. Complete Quick Start
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
```
The installer opens `nanobot onboard --wizard`. Choose **Quick Start** and follow the prompts:
To install the current `main` branch instead, pass `--dev`:
1. Choose the provider or endpoint that owns your credential.
2. Enter its API key or base URL when requested.
3. Enter a model ID that the same provider can run.
4. Let Quick Start enable the local WebUI.
5. Set a WebUI password and review the summary.
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
```
Quick Start creates or updates:
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
```
| Path | Purpose |
|---|---|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
If `curl` or `irm` is unavailable, or GitHub raw downloads are blocked on your network, use one of the manual install methods below.
If you prefer to inspect the script first, open [`../scripts/install.sh`](../scripts/install.sh) or [`../scripts/install.ps1`](../scripts/install.ps1).
**Stable release with `uv`:**
```bash
uv tool install nanobot-ai
nanobot --version
```
**Stable release with pip:**
```bash
python -m pip install nanobot-ai
nanobot --version
```
Use pip only inside an environment you control. If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment first.
**Latest source checkout:**
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install -e .
nanobot --version
```
If your shell cannot find `nanobot` after a pip install, run the module form:
```bash
python -m nanobot --version
python -m nanobot onboard
```
On Windows, `~` in the docs means your user profile directory, for example `C:\Users\you`.
The docs use `python` in commands. If your system exposes Python 3.11+ as `python3` or `py`, use that command in the same place, for example `python3 -m pip install nanobot-ai` or `py -m nanobot --version`.
## 2. Initialize
Skip this section if the one-command setup already started the wizard and Quick Start finished there.
```bash
nanobot onboard
```
Use the wizard if you prefer prompts instead of editing JSON by hand:
If the installer did not open the wizard, run it yourself:
```bash
nanobot onboard --wizard
```
Initialization creates:
Current source versions also provide `nanobot webui`. When run without a usable model, that launcher offers the same Quick Start flow before starting the browser.
| Path | What it is |
|------|------------|
| `~/.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 |
## 3. Check the Setup
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values. Use `nanobot onboard --refresh` to do the same refresh without an interactive prompt.
```bash
nanobot status
```
## 3. Configure a Provider
You want:
Skip this section if you already configured provider and model settings in the wizard.
- a check mark for **Config** and **Workspace**;
- the model or preset you selected;
- a configured state for the provider used by that model.
Open `~/.nanobot/config.json`. Add or merge these blocks into the file created by `nanobot onboard`; do not replace the whole file unless you want to reset the config.
Most other providers can say `not set`. This command validates local setup but does not call the model.
**API key:**
## 4. Get the First Reply
```bash
nanobot gateway
```
Quick Start has already prepared the local WebSocket channel. Leave the gateway terminal open and visit `http://127.0.0.1:8765`; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it. On current source versions, you can run `nanobot webui` instead to perform the local WebUI checks, start the gateway, and open the browser automatically.
Send:
```text
Hello!
```
Any normal assistant answer is success. It proves that nanobot can load the config, reach the selected model, use the workspace, and serve the browser UI.
Leave the terminal open while using the WebUI. If you prefer a managed background process, stop the foreground process with `Ctrl+C`, then run:
```bash
nanobot gateway --background
nanobot gateway status
```
Use `nanobot gateway logs`, `restart`, and `stop` to manage that background gateway.
## Terminal-Only Check
If you do not want the browser or need to isolate a WebUI problem, send one message directly:
```bash
nanobot agent -m "Hello!"
```
Then start an interactive terminal chat with:
```bash
nanobot agent
```
In interactive mode, `Enter` sends and `Alt+Enter` inserts a newline. Exit with `exit`, `/exit`, `:q`, or `Ctrl+D`.
## Choose One Next Step
After the first reply works, add one capability and test again:
| Goal | Recommended path |
|---|---|
| Learn sessions, workspaces, tools, and access modes | [WebUI guide](./webui.md) |
| Connect a chat platform | Open **Settings → Channels**, then use [Chat Apps](./chat-apps.md) for platform prerequisites |
| Change or add a model | Open **Settings → Models**; use the [Provider Cookbook](./provider-cookbook.md) for a recipe |
| Add web search, voice, or image generation | Use the matching WebUI Settings page, then consult [Configuration](./configuration.md) for advanced fields |
| Add an App or MCP integration | Open **Apps** or follow [Configure MCP Tools](./guides/configure-mcp-tools.md) |
| Schedule agent work | Read [Automations](./automations.md) |
| Run continuously or remotely | Read [Deployment](./deployment.md) |
| Integrate from code | Use the [Python SDK](./python-sdk.md) or [OpenAI-Compatible API](./openai-api.md) |
## Other Install Methods
Use one method, then continue at [Complete Quick Start](#2-complete-quick-start).
**uv**
```bash
uv tool install nanobot-ai
nanobot onboard --wizard
```
**pip in a virtual environment**
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
If pip reports `externally-managed-environment`, use the recommended installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment. Do not force a system-wide install.
**Current source**
`bun` or `npm` must be available. Activate a virtual environment first, then run:
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install .
nanobot onboard --wizard
```
On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install.
The source path follows current `main` and can be newer than the published package. A non-editable install triggers the build hook that bundles the current WebUI. For editable Python or frontend development, follow [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../webui/README.md`](../webui/README.md).
If the package is installed but the shell cannot find `nanobot`, use the runner that owns the installation. The recommended installer prints the exact command to reuse. Common forms are:
```bash
uv tool run --from nanobot-ai nanobot --version
pipx run --spec nanobot-ai nanobot --version
~/.nanobot/venv/bin/python -m nanobot --version
```
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `onboard --wizard`, `gateway`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
## Manual Configuration Fallback
Use this only when the wizard is unavailable or you intentionally manage JSON. First run `nanobot onboard`, then merge a provider and a named model preset into `~/.nanobot/config.json`.
A generic OpenAI-compatible setup has this shape:
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiKey": "${PROVIDER_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
```
**Model preset:**
```json
{
},
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 8192,
"contextWindowTokens": 65536,
"temperature": 0.1
"model": "model-id-from-your-provider"
}
},
"agents": {
@@ -158,192 +202,48 @@ Open `~/.nanobot/config.json`. Add or merge these blocks into the file created b
}
```
The provider and model inside a preset must match. The snippet above is only an example. For another provider, replace these values together:
| Replace | Where |
|---|---|
| Provider config key, such as `custom` | `providers.<provider>` |
| API key or environment variable | `providers.<provider>.apiKey` |
| Preset provider name | `modelPresets.primary.provider` |
| Model ID | `modelPresets.primary.model` |
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and fallback chains. For provider-specific examples across direct, gateway, OAuth, cloud, and local setups, see [`providers.md`](./providers.md).
**What about `apiBase` / base URL?**
`apiBase` is the HTTP base URL of the provider endpoint, not the model name. Most hosted providers in nanobot already know their default endpoint, so you usually only set `apiKey` and a model preset. Set `apiBase` when you are using:
- `custom` for a third-party or self-hosted OpenAI-compatible API;
- a local OpenAI-compatible server such as Ollama, vLLM, or LM Studio;
- a provider-specific alternate endpoint, regional endpoint, proxy, or subscription endpoint.
Examples:
```json
{
"providers": {
"custom": {
"apiKey": "${CUSTOM_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
```
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1"
}
}
}
```
If the provider's docs say the endpoint is `/v1`, include `/v1` in `apiBase`. The model ID still belongs in the active `modelPresets` entry.
If you prefer not to store secrets in `config.json`, reference an environment variable and set it before starting nanobot:
```json
{
"providers": {
"custom": {
"apiKey": "${PROVIDER_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
```
## 4. Check the Setup
```bash
nanobot status
```
This should show the config path, workspace path, active model or preset, and provider summary. It does not send a message to the model, so use it as a quick config check before the first real request.
Read it like this:
| Status line | What you want |
|---|---|
| `Config` | A check mark. |
| `Workspace` | A check mark. |
| `Model` | The model or preset you expect. |
| Provider list | Most providers can say `not set`; the provider used by the active preset should show a check mark, OAuth status, or local URL. |
## 5. Open the WebUI
Start the browser workbench:
```bash
nanobot webui
```
`nanobot webui` prepares the local WebSocket channel and WebUI bootstrap secret if needed, starts the gateway, and opens `http://127.0.0.1:8765`. First-run WebUI setup binds to `127.0.0.1` by default, so it is not exposed to your LAN. Use `nanobot webui --background` when you want the gateway to keep running without an open terminal.
## 6. Test One CLI Message
Use this path if you skipped Quick Start, declined the WebSocket channel, or want a terminal-only check.
Run a one-shot CLI message:
```bash
nanobot agent -m "Hello!"
```
A successful first run proves that:
- the `nanobot` command is installed;
- `~/.nanobot/config.json` can be loaded;
- the selected provider and model can answer;
- the default workspace can be created and used.
The reply text itself will vary. Any normal assistant answer means the install, config, provider, model, and workspace path are all usable.
If that works, start an interactive CLI chat:
```bash
nanobot agent
```
After the interactive session can answer normally, nanobot can help with its own next setup step. Ask it to read the relevant docs, inspect your current `~/.nanobot/config.json`, and make one concrete change such as enabling WebUI, adding a provider preset, or configuring one chat channel. When nanobot says the config is updated, run `/restart` in the chat or restart the nanobot process manually so long-running processes reload `config.json`.
Example prompt:
```text
Read docs/quick-start.md, docs/providers.md, and docs/configuration.md in this checkout.
Then update ~/.nanobot/config.json to add a model preset named "primary" for my provider.
Tell me exactly what changed and whether I need to run /restart.
```
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
## 7. Choose Your Next Step
| Want to... | Go to |
|---|---|
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
| Understand provider/model matching | [`providers.md`](./providers.md) |
| Open the bundled browser UI | [`webui.md`](./webui.md) |
| Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [`chat-apps.md`](./chat-apps.md) |
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
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.
## Updating
**pip:**
Upgrade with the same method you used to install:
```bash
python -m pip install -U nanobot-ai
nanobot --version
```
# Recommended installer
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
If pip reports `externally-managed-environment`, upgrade with the same isolated method you used to install nanobot, such as `uv tool upgrade nanobot-ai`, `pipx upgrade nanobot-ai`, or the managed venv created by the one-command installer.
**uv:**
```bash
# Or one of these
uv tool upgrade nanobot-ai
nanobot --version
```
**pipx:**
```bash
pipx upgrade nanobot-ai
nanobot --version
python -m pip install -U nanobot-ai
```
**Source checkout:**
For a source checkout:
```bash
git pull
python -m pip install -e .
nanobot --version
python -m pip install .
```
If you use WhatsApp from a source checkout, keep the optional dependencies installed:
Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings.
## If the First Reply Fails
Do not change several settings at once. Start with:
```bash
nanobot plugins enable whatsapp
nanobot --version
nanobot status
nanobot agent -m "Hello!"
```
## First-Run Troubleshooting
| Symptom | First check |
|---|---|
| `nanobot: command not found` | Reuse the installer command or method-specific runner described under [Other Install Methods](#other-install-methods) |
| JSON parse error | Check commas and braces; remember that docs examples are usually snippets |
| `401` or invalid API key | Verify the selected provider owns that key and remove accidental spaces |
| Model not found | Use a model ID available from the provider selected in the active preset |
| CLI works but WebUI does not open | Use port `8765`, not gateway health port `18790` |
| WebUI works but a chat app does not | Check **Settings → Channels**, then run `nanobot channels status` |
| Symptom | What to check |
|---------|---------------|
| `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).
Continue with the ordered [Troubleshooting guide](./troubleshooting.md) if the cause is still unclear.
+99 -337
View File
@@ -1,76 +1,62 @@
# Start Without Technical Background
This page is for you if you have never used a terminal, edited a JSON file, or configured an AI model before.
This walkthrough is for people who have not used a terminal, API key, or JSON config file before. The goal is only to get one reply in a browser. You do not need to understand nanobot's architecture or edit its config by hand.
The goal is small: get one local nanobot reply in your browser. Do not connect Telegram, Discord, Docker, local models, or deployment yet. Those are easier after the first reply works.
## What You Will Need
## What You Are Setting Up
- A Windows, macOS, or Linux computer.
- Python 3.11 or newer.
- An account or endpoint that can run an AI model.
- The API key, login, endpoint, and model name required by that service. A local model such as Ollama may not require an API key.
You only need these words for Quick Start:
An API key is password-like. Do not post it in an issue, screenshot, chat, or public config file.
| Word | Plain meaning |
## A Few Useful Words
| Word | Meaning |
|---|---|
| Terminal | A text window where you paste commands and press Enter. |
| Command | One line of text you run in the terminal. |
| API key | A password-like token from an AI provider. Do not share it publicly. |
| Config file | The settings file nanobot reads when it starts. |
| Wizard | An interactive terminal menu that edits the config file for you. |
| Browser UI | The local web page where you chat with nanobot. |
| Terminal | A text window where you paste a command and press Enter |
| Command | One instruction typed into the terminal |
| Provider | The service or local server that runs the AI model |
| Model ID | The exact model name expected by that provider |
| API key | A secret credential that lets software call the provider |
| Wizard | A question-and-answer setup menu |
| WebUI | The local browser page where you use nanobot |
## 1. Open a Terminal
## 1. Install Python
You will paste commands into a terminal. Copy only the command text inside each code block; do not copy the ``` marks.
Download Python from [python.org](https://www.python.org/downloads/) if you do not already have version 3.11 or newer. On Windows, enable **Add python.exe to PATH** if the installer shows that option.
| System | How to open it |
Open a terminal:
| System | How |
|---|---|
| Windows | Press `Win`, type `PowerShell`, then open **Windows PowerShell**. |
| macOS | Press `Command` + `Space`, type `Terminal`, then press `Enter`. |
| Linux | Open your app launcher, search for `Terminal`, then open it. |
| Windows | Press `Win`, type `PowerShell`, and open Windows PowerShell |
| macOS | Press `Command+Space`, type `Terminal`, and press Enter |
| Linux | Open your application menu and search for Terminal |
When the terminal opens, click inside it, paste the command, and press `Enter`. If a command prints text and returns to a prompt, that is usually normal.
## 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:
Check Python:
```bash
python --version
```
If Windows says `python` is not found, close and reopen PowerShell. If it still does not work, try:
The result should start with `Python 3.11` or a newer number. If the command is not found, close and reopen the terminal. You can also try `python3 --version` on macOS/Linux or `py --version` on Windows.
```bash
py --version
```
## 2. Prepare Your Model Details
If `py` works but `python` does not, replace `python` with `py` in the commands below.
nanobot does not create an AI provider account for you. Before setup, have these details nearby:
If macOS or Linux says `python` is not found, try:
1. The provider or company endpoint name.
2. Its API key, if it requires one.
3. Its base URL, if its documentation gives you one.
4. A model ID your account can use.
```bash
python3 --version
```
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.
If `python3` works but `python` does not, replace `python` with `python3` in the manual commands below. The one-command installer already checks both `python3` and `python`.
## 3. Install nanobot
## 3. Get a Provider API Key
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`.
Copy the command for your system, paste it into the terminal, and press Enter. Copy only the text inside the code block.
**macOS / Linux**
@@ -84,75 +70,13 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
```
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`:
The installer downloads the stable nanobot package into an isolated Python environment and opens the setup wizard. It can take a few minutes on the first run. When it finishes, it prints the exact command it used to run nanobot. Keep that command: if `nanobot` is not found later, reuse the whole printed command instead of switching to a different Python command.
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --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.
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
```
## 4. Follow Quick Start
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:
The wizard shows a menu similar to:
```text
> What would you like to do?
@@ -161,259 +85,97 @@ You will see a menu like this:
[X] Exit
```
Move through the wizard like this:
Choose **Quick Start**. Use the arrow keys to highlight an option and press `Enter`.
| When you see | Do this |
|---|---|
| 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. |
The wizard asks for only the information needed for the first reply:
For the first setup, choose `[Q] Quick Start`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a chat app, a tool setup, or provider-specific fields.
1. Choose your provider.
2. Choose an endpoint option if the provider offers several plans.
3. Paste the API key if asked.
4. Enter the base URL if asked.
5. Enter a model ID.
6. Confirm the local WebUI setup.
7. Choose a WebUI password.
8. Review the summary and save.
1. Choose `[Q] Quick Start`.
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.
When you paste a password or API key, the terminal may hide the characters. That is normal.
The recommended path configures the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
The wizard creates or updates:
| Path | Meaning |
|---|---|
| `~/.nanobot/config.json` | Settings file. |
| `~/.nanobot/workspace/` | Working folder for memory, sessions, and generated files. |
If Quick Start finished successfully, skip to [Open the WebUI](#7-open-the-webui). The next two sections are only for manual setup.
## Manual Setup: How to Merge JSON Snippets
Most docs examples are snippets, not whole files. Your `config.json` has one outer `{ ... }`. Add new top-level sections such as `providers`, `modelPresets`, `agents`, or `channels` inside that same outer object.
Do not paste two separate JSON objects into one file:
```text
{
"providers": { "...": "..." }
}
{
"channels": { "...": "..." }
}
```
Merge them into one object:
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
},
"channels": {
"websocket": {
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard --wizard` whenever possible.
## 6. Manual Setup: Config Fallback
Use this only if the wizard is unavailable or you prefer opening the file yourself.
Run `nanobot onboard` first if `~/.nanobot/config.json` does not exist yet.
Use one of these commands:
**Windows PowerShell**
```powershell
notepad "$env:USERPROFILE\.nanobot\config.json"
```
**macOS**
If the installer finishes without opening the wizard and `nanobot` is available, run:
```bash
open -e ~/.nanobot/config.json
nanobot onboard --wizard
```
**Linux**
If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `onboard --wizard`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
## 5. Open the Browser
Run:
```bash
xdg-open ~/.nanobot/config.json
nanobot gateway
```
If this is a brand-new install and you have not configured anything else yet, replace the file with this minimal config:
Leave the terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password from the wizard if the browser asks for it. Current source versions also provide `nanobot webui`, which starts the gateway and opens the browser automatically.
```json
{
"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:
Send this message:
```text
Hello!
```
If that works, nanobot is installed and can call the model. You should see a normal assistant reply in the browser. The exact words will differ, but it should look like this shape:
A normal assistant reply means setup is complete. The exact reply does not matter.
```text
Hello! How can I help you today?
```
The first-run address is local to your computer. It is not automatically available to other computers on your network.
If `nanobot` is not found, run:
## 6. Add One Thing at a Time
Do not configure every feature immediately. Choose one next goal:
| Goal | What to do |
|---|---|
| Change the AI model | Open **Settings → Models** |
| Add a provider credential | Open **Settings → Models**, then find the provider |
| Connect Telegram, Discord, Slack, Feishu, WeChat, or another chat app | Open **Settings → Channels**, choose the platform, and follow its connection steps |
| Add a tool integration | Open **Apps** and choose an App or MCP integration |
| Schedule a reminder or recurring task | Ask nanobot in the target chat, then manage it in **Automations** |
| Work with project files | Start a new chat, choose the project workspace, and review the access setting before sending the task |
Repository docs show the current development version. If your stable package does not yet show **Settings → Channels**, use the [Chat Apps guide](./chat-apps.md) or update to a release that includes it.
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot gateway` again.
For a chat platform's account, bot, token, or permission prerequisites, use the [Chat Apps guide](./chat-apps.md). For local models and provider-specific recipes, use the [Provider Cookbook](./provider-cookbook.md).
## If Something Fails
Run these commands one at a time:
```bash
python -m nanobot webui
nanobot --version
nanobot status
nanobot agent -m "Hello!"
```
Use `python3 -m nanobot webui` or `py -m nanobot webui` if that is the Python command that worked in step 2.
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 |
| What you see | What it usually means |
|---|---|
| `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 API key is wrong, expired, has extra spaces, or was pasted under the wrong provider. |
| `model not found` | Your account cannot use the default model. Return to `nanobot onboard --wizard`, choose `Advanced Settings`, then edit `Model Presets`. |
| `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. |
| No response after editing config | Restart the command. Long-running processes read config when they start. |
| `nanobot: command not found` | Reuse the exact nanobot command printed by the installer; it points to the isolated environment that contains the package |
| `401`, unauthorized, or invalid API key | The key is wrong, expired, or belongs to a different provider |
| Model not found | The model ID is misspelled or unavailable to your provider account |
| Browser does not open | Open `http://127.0.0.1:8765` yourself and keep the terminal running |
| Browser opens but messages fail | Test `nanobot agent -m "Hello!"` to separate a model problem from a WebUI problem |
| A change was saved but nothing changed | Restart nanobot so the running process reloads the config |
For a fuller diagnosis path, see [`troubleshooting.md`](./troubleshooting.md).
If you ask for help, include your operating system, `nanobot --version`, `nanobot status`, the exact command, and the exact error. Remove every API key, bot token, password, OAuth token, and private account ID first.
## What Not to Configure Yet
Continue with the full [Troubleshooting guide](./troubleshooting.md) for an ordered diagnosis.
Skip these until the first local message works:
- `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
## Open nanobot Later
Run:
```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
```
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>.
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`.
+30 -3
View File
@@ -53,6 +53,18 @@ WebUI beyond localhost or want a browser password:
The WebUI is served by the WebSocket channel on port `8765` by default. The
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
| Area | Use it for |
@@ -62,6 +74,7 @@ gateway health endpoint, `18790` by default, is not the browser UI.
| Workspace | Pick the project workspace before asking for file or shell work |
| 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 |
| 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 |
| 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 |
@@ -113,6 +126,20 @@ For image generation, configure an image provider first and then use the WebUI
image mode from the composer. See [`image-generation.md`](./image-generation.md)
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
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
@@ -194,9 +221,9 @@ with the content that should be delivered.
## Settings
Settings is the control surface for the browser session and gateway-backed
runtime configuration. Use it to review or adjust model presets, provider
visibility, image generation, voice transcription, web tools, Apps, Automations,
Skills, runtime identity, and advanced safety controls.
runtime configuration. Use it to review or adjust model presets, providers,
image generation, voice transcription, web tools, chat channels, Apps,
Automations, Skills, runtime identity, and advanced safety controls.
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
+40
View File
@@ -1,5 +1,44 @@
#!/bin/sh
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
owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null)
cat >&2 <<EOF
@@ -12,4 +51,5 @@ Fix (pick one):
EOF
exit 1
fi
exec nanobot "$@"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 657 KiB

+11 -6
View File
@@ -425,9 +425,14 @@ class ContextGovernor:
return system_messages + self._legal_history_tail(kept, non_system)
@staticmethod
def _summary_for(message: dict[str, Any]) -> str:
def _tool_result_compaction_message(message: dict[str, Any]) -> str:
name = message.get("name", "tool")
return f"[Prior {name} result compacted to fit context; the tool call already completed.]"
return (
f"Error: The previous {name} result was compacted to fit context because it was too "
"large. Do not repeat the same call unchanged. Retry with a narrower path, query, "
"range, or result limit, use another tool, or tell the user the task cannot fit in "
"the available context."
)
def _legal_history_tail(
self,
@@ -462,12 +467,12 @@ class ContextGovernor:
tool_call_id = msg.get("tool_call_id")
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
continue
summary = self._summary_for(msg)
if msg.get("content") == summary:
compaction_message = self._tool_result_compaction_message(msg)
if msg.get("content") == compaction_message:
continue
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = summary
updated[idx]["content"] = compaction_message
return updated
def _inflight_compaction_candidates(
@@ -500,4 +505,4 @@ class ContextGovernor:
return primary + fallback
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
messages[idx]["content"] = self._summary_for(messages[idx])
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
+8 -1
View File
@@ -28,6 +28,7 @@ from nanobot.agent.model_runtime import ModelRuntimeResolver
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
from nanobot.agent.subagent import SubagentManager
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.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
@@ -80,6 +81,7 @@ from nanobot.session.manager import (
replay_max_messages_for_context,
)
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.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
@@ -355,6 +357,7 @@ class AgentLoop:
# One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars.
self._file_state_store = FileStateStore()
self._exec_session_manager = ExecSessionManager()
self.runner = AgentRunner()
self.subagents = SubagentManager(
workspace=workspace,
@@ -540,6 +543,7 @@ class AgentLoop:
bus=self.bus,
subagent_manager=self.subagents,
cron_service=self.cron_service,
exec_session_manager=self._exec_session_manager,
sessions=self.sessions,
provider_snapshot_loader=provider_snapshot_loader,
image_generation_provider_configs=self._image_generation_provider_configs,
@@ -995,8 +999,11 @@ class AgentLoop:
except asyncio.CancelledError:
# Preserve real task cancellation so shutdown can complete cleanly.
# Only ignore non-task CancelledError signals that may leak from integrations.
if not self._running or asyncio.current_task().cancelling():
if not self._running or task_is_cancelling():
raise
logger.warning(
"Ignoring leaked CancelledError while consuming inbound messages"
)
continue
except Exception as e:
logger.warning("Error consuming inbound message: {}, continuing...", e)
+21 -21
View File
@@ -29,6 +29,12 @@ from nanobot.utils.helpers import (
truncate_text_to_tokens,
)
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:
from nanobot.utils.llm_runtime import LLMRuntime
@@ -492,14 +498,10 @@ class MemoryStore:
@property
def dream_prompt_file(self) -> Path:
return self.workspace / "prompts" / "dream.md"
return workspace_prompt_file(self.workspace, "dream")
def has_dream_prompt_override(self) -> bool:
with suppress(OSError):
return self.dream_prompt_file.is_file() and bool(
self.dream_prompt_file.read_text(encoding="utf-8").strip()
)
return False
return has_workspace_prompt_override(self.dream_prompt_file)
@staticmethod
def default_dream_prompt() -> str:
@@ -512,20 +514,19 @@ class MemoryStore:
)
def _dream_template(self) -> str:
with suppress(OSError):
text = self.dream_prompt_file.read_text(encoding="utf-8")
if text.strip():
text = text.rstrip()
if len(text) > _DREAM_PROMPT_MAX_CHARS:
if not self._dream_prompt_oversize_logged:
self._dream_prompt_oversize_logged = True
logger.warning(
"workspace Dream prompt exceeds {} chars ({}); truncating. "
"Further occurrences suppressed.",
_DREAM_PROMPT_MAX_CHARS, len(text),
)
return truncate_text(text, _DREAM_PROMPT_MAX_CHARS)
return text
text, original_chars = load_workspace_prompt_override(self.dream_prompt_file)
if text is not None:
if (
original_chars > WORKSPACE_PROMPT_MAX_CHARS
and not self._dream_prompt_oversize_logged
):
self._dream_prompt_oversize_logged = True
logger.warning(
"workspace Dream prompt exceeds {} chars ({}); truncating. "
"Further occurrences suppressed.",
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
)
return text
return self.default_dream_prompt()
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
@@ -734,7 +735,6 @@ class MemoryStore:
# that catches any new caller that forgot to set its own cap.
_RAW_ARCHIVE_MAX_CHARS = 16_000 # fallback dump (LLM failed)
_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
+18 -11
View File
@@ -806,11 +806,17 @@ class AgentRunner:
else:
coro = spec.runtime.provider.chat_with_retry(**kwargs)
# Streaming requests already have provider-level idle timeouts
# (NANOBOT_STREAM_IDLE_TIMEOUT_S). Do not also apply the outer wall-clock
# LLM timeout here, or healthy long reasoning streams can be killed just
# because total elapsed time exceeded NANOBOT_LLM_TIMEOUT_S.
outer_timeout_s = None if (wants_streaming or wants_progress_streaming) else timeout_s
# Streaming requests also have provider-level idle timeouts
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
# very slow deltas can still run forever. Use a more generous wall-clock
# timeout for streaming while preserving NANOBOT_LLM_TIMEOUT_S=0 as an
# opt-out for all LLM wall-clock timeouts.
is_streaming_request = wants_streaming or wants_progress_streaming
outer_timeout_s = (
max(300.0, timeout_s * 2)
if is_streaming_request and timeout_s is not None
else timeout_s
)
try:
response = (
await coro if outer_timeout_s is None
@@ -818,16 +824,17 @@ class AgentRunner:
)
except asyncio.TimeoutError:
if outer_timeout_s is None:
return LLMResponse(
response = LLMResponse(
content="Error calling LLM: stream stalled",
finish_reason="error",
error_kind="timeout",
)
return LLMResponse(
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
finish_reason="error",
error_kind="timeout",
)
else:
response = LLMResponse(
content=f"Error calling LLM: timed out after {outer_timeout_s:g}s",
finish_reason="error",
error_kind="timeout",
)
if progress_state and progress_state.get("reasoning_open"):
await hook.emit_reasoning_end()
dropped, all_dropped, original_finish_reason = (
+3
View File
@@ -19,6 +19,7 @@ from nanobot.agent.tools.context import (
bind_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.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry
@@ -143,6 +144,7 @@ class SubagentManager:
else defaults.fail_on_tool_error
)
self.runner = AgentRunner()
self._exec_session_manager = ExecSessionManager()
self._llm_wall_timeout_for_session = llm_wall_timeout_for_session
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_statuses: dict[str, SubagentStatus] = {}
@@ -204,6 +206,7 @@ class SubagentManager:
ctx = ToolContext(
config=cfg,
workspace=str(root.resolve()),
exec_session_manager=self._exec_session_manager,
file_state_store=FileStates(),
workspace_sandbox=workspace_sandbox_status(
restrict_to_workspace=cfg.restrict_to_workspace,
+1
View File
@@ -71,6 +71,7 @@ class ToolContext:
bus: Any | None = None
subagent_manager: Any | None = None
cron_service: Any | None = None
exec_session_manager: Any | None = None
sessions: Any | None = None
file_state_store: Any = field(default=None)
provider_snapshot_loader: Callable[[], Any] | None = None
+4 -10
View File
@@ -250,11 +250,7 @@ class ExecSessionManager:
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
if (
owner_session_key
and session.owner_session_key
and session.owner_session_key != owner_session_key
):
if session.owner_session_key and session.owner_session_key != owner_session_key:
raise KeyError(session_id)
if chars:
@@ -296,9 +292,7 @@ class ExecSessionManager:
owner_session_key=session.owner_session_key,
)
for session_id, session in sorted(self._sessions.items())
if not owner_session_key
or not session.owner_session_key
or session.owner_session_key == owner_session_key
if session.owner_session_key == owner_session_key
]
async def _cleanup_locked(self) -> None:
@@ -442,7 +436,7 @@ class WriteStdinTool(Tool):
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
return cls(manager=getattr(ctx, "exec_session_manager", None))
@property
def exclusive(self) -> bool:
@@ -586,7 +580,7 @@ class ListExecSessionsTool(Tool):
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
return cls(manager=getattr(ctx, "exec_session_manager", None))
@property
def name(self) -> str:
+1
View File
@@ -129,6 +129,7 @@ class ImageGenerationTool(Tool):
"api_base": provider.api_base if provider else None,
"extra_headers": provider.extra_headers if provider else None,
"extra_body": provider.extra_body if provider else None,
"proxy": provider.proxy if provider else None,
}
return cls(**kwargs)
+14 -6
View File
@@ -30,6 +30,7 @@ from nanobot.security.network import (
resolve_url_target,
validate_url_target,
)
from nanobot.utils.cancellation import task_is_cancelling
# Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network
@@ -487,8 +488,7 @@ class MCPToolWrapper(_MCPWrapperBase):
except asyncio.CancelledError:
# MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
# Re-raise only if our task was externally cancelled (e.g. /stop).
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
if task_is_cancelling():
raise
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
return ToolResult.error("(MCP tool call was cancelled)")
@@ -650,8 +650,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
)
return f"(MCP resource read timed out after {self._resource_timeout}s)"
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
if task_is_cancelling():
raise
logger.warning("MCP resource '{}' was cancelled by server/SDK", self._name)
return "(MCP resource read was cancelled)"
@@ -764,8 +763,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
)
return f"(MCP prompt call timed out after {self._prompt_timeout}s)"
except asyncio.CancelledError:
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
if task_is_cancelling():
raise
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
@@ -1145,6 +1143,8 @@ async def connect_missing_servers(state: Any, registry: ToolRegistry) -> None:
else:
logger.warning("No MCP servers connected successfully (will retry next message)")
except asyncio.CancelledError:
if task_is_cancelling():
raise
logger.warning("MCP connection cancelled (will retry next message)")
except BaseException as e:
logger.warning("Failed to connect MCP servers (will retry next message): {}", e)
@@ -1410,6 +1410,10 @@ async def _close_server(state: Any, server_name: str) -> None:
return
try:
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):
logger.debug("MCP server '{}' cleanup error (can be ignored)", server_name)
@@ -1423,5 +1427,9 @@ async def close_mcp_servers(state: Any) -> None:
for name, connection in connections:
try:
await connection.aclose()
except asyncio.CancelledError:
if task_is_cancelling():
raise
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
except (RuntimeError, BaseExceptionGroup):
logger.debug("MCP server '{}' cleanup error (can be ignored)", name)
+7 -1
View File
@@ -188,6 +188,7 @@ class ExecTool(Tool):
allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns,
deny_patterns=cfg.deny_patterns,
session_manager=getattr(ctx, "exec_session_manager", None),
)
def __init__(
@@ -535,7 +536,12 @@ class ExecTool(Tool):
env=cmd_env,
)
command = ExecTool._normalize_powershell_command(command)
command = f"{command}\nif ($LASTEXITCODE -ne $null) {{ exit $LASTEXITCODE }}"
command = (
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
f"{command}\n"
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
)
return await asyncio.create_subprocess_exec(
program, "-NoProfile", "-NonInteractive", "-Command", command,
stdin=stdin,
+38 -9
View File
@@ -41,6 +41,26 @@ __all__ = (
API_SESSION_KEY = "api:default"
API_CHAT_ID = "default"
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
_SESSION_LOCKS_KEY = web.AppKey[dict]("session_locks")
_MISSING = object()
def _app_value(
app: Any,
key: web.AppKey[Any],
legacy_key: str,
default: Any = _MISSING,
) -> Any:
"""Read typed aiohttp state while accepting lightweight dict test doubles."""
try:
return app[key]
except KeyError:
if default is _MISSING:
return app[legacy_key]
return app.get(legacy_key, default)
# ---------------------------------------------------------------------------
@@ -209,9 +229,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
if not isinstance(content_type, str):
content_type = ""
agent_loop = request.app["agent_loop"]
timeout_s: float = request.app.get("request_timeout", 120.0)
model_name: str = request.app.get("model_name", "nanobot")
agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
timeout_s: float = _app_value(
request.app,
_REQUEST_TIMEOUT_KEY,
"request_timeout",
120.0,
)
model_name: str = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
stream = False
try:
@@ -238,7 +263,11 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
return _error_json(400, f"Only configured model '{model_name}' is available")
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
session_locks: dict[str, asyncio.Lock] = _app_value(
request.app,
_SESSION_LOCKS_KEY,
"session_locks",
)
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
logger.info(
@@ -366,7 +395,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
async def handle_models(request: web.Request) -> web.Response:
"""GET /v1/models"""
model_name = request.app.get("model_name", "nanobot")
model_name = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
return web.json_response(
{
"object": "list",
@@ -407,10 +436,10 @@ def create_app(
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["agent_loop"] = agent_loop
app["model_name"] = model_name
app["request_timeout"] = request_timeout
app["session_locks"] = {} # per-user locks, keyed by session_key
app[_AGENT_LOOP_KEY] = agent_loop
app[_MODEL_NAME_KEY] = model_name
app[_REQUEST_TIMEOUT_KEY] = request_timeout
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
@web.middleware
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
+32 -4
View File
@@ -188,6 +188,8 @@ _BRAND_ALIASES: dict[str, str] = {
"lark-cli": "feishu",
"minimax-cli": "minimax",
"obsidian-cli": "obsidian",
"obsidian-agent": "obsidian",
"obsidian-agent-cli": "obsidian",
"slay-the-spire-2": "slay-the-spire-ii",
"slay-the-spire-ii": "slay-the-spire-ii",
"unimol-tools": "unimol-tools",
@@ -761,19 +763,30 @@ class CliAppManager:
def installed_payload(self) -> dict[str, Any]:
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 = []
for name, raw_entry in sorted(installed.items()):
entry = raw_entry if isinstance(raw_entry, dict) else {}
strategy = str(entry.get("strategy") or "bundled")
cached_app = cached_by_name.get(str(name).lower(), {})
app = {
"name": str(name),
"display_name": str(entry.get("display_name") or name),
"category": str(entry.get("category") or "installed"),
"description": str(entry.get("description") or ""),
"requires": str(entry.get("requires") or ""),
"display_name": str(
cached_app.get("display_name") or entry.get("display_name") or name
),
"category": str(cached_app.get("category") or entry.get("category") or "installed"),
"description": str(cached_app.get("description") or entry.get("description") or ""),
"requires": str(cached_app.get("requires") or entry.get("requires") or ""),
"_source": str(entry.get("source") or "local"),
"entry_point": str(entry.get("entry_point") or ""),
"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))
return {
@@ -948,6 +961,8 @@ class CliAppManager:
argv,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command)
@@ -966,6 +981,17 @@ class CliAppManager:
"strategy": strategy,
"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
if resolved:
entry["entry_point_path"] = resolved
@@ -1340,6 +1366,8 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
cwd=str(cwd),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=effective_timeout,
env=os.environ.copy(),
)
+2 -3
View File
@@ -1,6 +1,5 @@
"""Chat channels module with plugin architecture."""
"""Shared contracts for chat channels."""
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
__all__ = ["BaseChannel", "ChannelManager"]
__all__ = ["BaseChannel"]
+40
View File
@@ -0,0 +1,40 @@
"""Small constructors shared by declarative channel manifests."""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from nanobot.channels.contracts import ChannelFieldSpec, FieldKind, SetupRequirement
GROUP_POLICIES = frozenset({"mention", "open", "allowlist"})
DIRECT_GROUP_POLICIES = frozenset({"mention", "open"})
def field(
kind: FieldKind = "string",
*,
choices: Iterable[str] = (),
default: Any = None,
writable: bool = True,
snapshot: bool = True,
) -> ChannelFieldSpec:
return ChannelFieldSpec(
kind=kind,
choices=frozenset(choices),
default=default,
writable=writable,
snapshot=snapshot,
)
def required(name: str) -> SetupRequirement:
return SetupRequirement.field(name)
def required_fields(*names: str) -> tuple[SetupRequirement, ...]:
return tuple(required(name) for name in names)
def one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
return SetupRequirement.one_of(*alternatives)
+15 -335
View File
@@ -1,343 +1,23 @@
"""Shared channel setup contract for configuration, display, and validation."""
"""Resolve channel-owned setup contracts for settings consumers."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
from typing import TYPE_CHECKING
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
RouteFieldType = str | tuple[str, set[str]]
from nanobot.channels.contracts import ChannelSetupSpec
if TYPE_CHECKING:
from nanobot.channels.plugin import ChannelPlugin
@dataclass(frozen=True)
class ChannelFieldSpec:
"""One channel field exposed through the settings contract."""
kind: FieldKind = "string"
choices: frozenset[str] = frozenset()
writable: bool = True
snapshot: bool = True
@property
def route_type(self) -> RouteFieldType:
if self.kind == "enum":
return ("enum", set(self.choices))
return self.kind
@dataclass(frozen=True)
class SetupRequirement:
"""A requirement satisfied by any one complete field group."""
alternatives: tuple[tuple[str, ...], ...]
def is_satisfied(self, values: Any) -> bool:
return any(
all(channel_value_present(channel_field_value(values, field)) for field in group)
for group in self.alternatives
)
@property
def simple_field(self) -> str | None:
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
return self.alternatives[0][0]
return None
@dataclass(frozen=True)
class ChannelSetupSpec:
"""Save, display, and validation contract for one channel."""
fields: dict[str, ChannelFieldSpec]
required: tuple[SetupRequirement, ...] = ()
official_url: str | None = None
@property
def secrets(self) -> frozenset[str]:
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
@property
def snapshot_fields(self) -> tuple[str, ...]:
return tuple(name for name, field in self.fields.items() if field.snapshot)
@property
def route_field_types(self) -> dict[str, RouteFieldType]:
return {
name: field.route_type
for name, field in self.fields.items()
if field.writable
}
@property
def simple_required_fields(self) -> tuple[str, ...]:
return tuple(
field
for requirement in self.required
if (field := requirement.simple_field) is not None
)
def is_configured(self, values: Any) -> bool:
return bool(self.required) and all(
requirement.is_satisfied(values) for requirement in self.required
)
def _field(
kind: FieldKind = "string",
def channel_setup_spec(
name: str,
*,
choices: set[str] | None = None,
writable: bool = True,
snapshot: bool = True,
) -> ChannelFieldSpec:
return ChannelFieldSpec(
kind=kind,
choices=frozenset(choices or ()),
writable=writable,
snapshot=snapshot,
)
plugin: ChannelPlugin | None = None,
) -> ChannelSetupSpec | None:
"""Return the setup contract declared by one channel descriptor."""
if plugin is None:
from nanobot.channels.registry import load_channel_plugin
def _required(field: str) -> SetupRequirement:
return SetupRequirement(((field,),))
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
return SetupRequirement(alternatives)
_GROUP_POLICIES = {"mention", "open", "allowlist"}
_DIRECT_GROUP_POLICIES = {"mention", "open"}
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
"websocket": ChannelSetupSpec(
fields={},
official_url="http://127.0.0.1:8765",
),
"telegram": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list"),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
},
required=(_required("token"),),
official_url="https://t.me/BotFather",
),
"slack": ChannelSetupSpec(
fields={
"appToken": _field("secret"),
"botToken": _field("secret"),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
},
required=(_required("appToken"), _required("botToken")),
official_url="https://api.slack.com/apps",
),
"discord": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list", snapshot=False),
"allowChannels": _field("list"),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
},
required=(_required("token"),),
official_url="https://discord.com/developers/applications",
),
"email": ChannelSetupSpec(
fields={
"consentGranted": _field("bool"),
"imapHost": _field(),
"imapPort": _field("int"),
"imapUsername": _field(),
"imapPassword": _field("secret"),
"smtpHost": _field(),
"smtpPort": _field("int"),
"smtpUsername": _field(),
"smtpPassword": _field("secret"),
"fromAddress": _field(),
"pollIntervalSeconds": _field("int"),
"allowFrom": _field("list"),
"verifyDkim": _field("bool"),
"verifySpf": _field("bool"),
},
required=tuple(
_required(field)
for field in (
"consentGranted",
"imapHost",
"imapUsername",
"imapPassword",
"smtpHost",
"smtpUsername",
"smtpPassword",
)
),
official_url="https://support.google.com/accounts/answer/185833",
),
"matrix": ChannelSetupSpec(
fields={
"homeserver": _field(),
"userId": _field(),
"password": _field("secret"),
"accessToken": _field("secret"),
"deviceId": _field(),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
"allowFrom": _field("list", writable=False),
},
required=(
_required("homeserver"),
_required("userId"),
_one_of(("password",), ("accessToken", "deviceId")),
),
official_url="https://matrix.org/ecosystem/clients/",
),
"mattermost": ChannelSetupSpec(
fields={
"serverUrl": _field(),
"token": _field("secret"),
"teamId": _field(),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
"allowFrom": _field("list"),
},
required=(_required("serverUrl"), _required("token")),
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
),
"whatsapp": ChannelSetupSpec(
fields={
"allowFrom": _field("list", snapshot=False),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
"databasePath": _field(writable=False, snapshot=False),
},
official_url="https://faq.whatsapp.com/",
),
"dingtalk": ChannelSetupSpec(
fields={
"clientId": _field(),
"clientSecret": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("clientId"), _required("clientSecret")),
official_url="https://open.dingtalk.com/",
),
"wecom": ChannelSetupSpec(
fields={
"botId": _field(),
"secret": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("botId"), _required("secret")),
official_url="https://developer.work.weixin.qq.com/",
),
"weixin": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("token"),),
official_url="https://weixin.qq.com/",
),
"qq": ChannelSetupSpec(
fields={
"appId": _field(),
"secret": _field("secret"),
"allowFrom": _field("list"),
"msgFormat": _field("enum", choices={"plain", "markdown"}),
},
required=(_required("appId"), _required("secret")),
official_url="https://q.qq.com/",
),
"signal": ChannelSetupSpec(
fields={
"phoneNumber": _field(),
"daemonHost": _field(),
"daemonPort": _field("int"),
"allowFrom": _field("list", snapshot=False),
"dm.allowFrom": _field("list"),
"group.allowFrom": _field("list"),
},
required=(_required("phoneNumber"),),
official_url="https://github.com/bbernhard/signal-cli-rest-api",
),
"msteams": ChannelSetupSpec(
fields={
"appId": _field(),
"appPassword": _field("secret"),
"tenantId": _field(),
"path": _field(),
"allowFrom": _field("list"),
},
required=(_required("appId"), _required("appPassword")),
official_url="https://dev.teams.microsoft.com/apps",
),
"napcat": ChannelSetupSpec(
fields={
"wsUrl": _field(),
"accessToken": _field("secret"),
"allowFrom": _field("list"),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
},
required=(_required("wsUrl"),),
official_url="https://napneko.github.io/",
),
"feishu": ChannelSetupSpec(
fields={
"appId": _field(snapshot=False),
"appSecret": _field("secret", snapshot=False),
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
"groupPolicy": _field(
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
),
"allowFrom": _field("list", snapshot=False),
"topicIsolation": _field("bool", snapshot=False),
},
required=(_required("appId"), _required("appSecret")),
official_url="https://open.feishu.cn/app",
),
}
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
return CHANNEL_SETUP_SPECS.get(name)
def channel_field_value(values: Any, field_path: str) -> Any:
current = values
for part in field_path.split("."):
candidates = (part, _camel_to_snake(part))
if isinstance(current, dict):
for candidate in candidates:
if candidate in current:
current = current[candidate]
break
else:
return None
continue
for candidate in candidates:
if hasattr(current, candidate):
current = getattr(current, candidate)
break
else:
return None
return current
def channel_value_present(value: Any) -> bool:
return value not in (None, "", [], {})
def stringify_channel_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
def _camel_to_snake(value: str) -> str:
chars: list[str] = []
for char in value:
if char.isupper():
if chars:
chars.append("_")
chars.append(char.lower())
else:
chars.append(char)
return "".join(chars)
plugin = load_channel_plugin(name)
return plugin.setup
+20 -2
View File
@@ -224,9 +224,17 @@ class BaseChannel(ABC):
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
is_dm: bool = False,
authorization_id: str | None = None,
) -> None:
"""Handle an incoming message: check permissions, issue pairing codes in DMs, or forward to bus."""
if not self.is_allowed(sender_id):
"""Handle a message after checking its authorization subject.
``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:
code = generate_code(self.name, str(sender_id))
await self.send(
@@ -270,6 +278,16 @@ class BaseChannel(ABC):
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
return {"enabled": False}
@classmethod
def refresh_feature_metadata(
cls,
config_path: Path,
*,
instance_id: str = "default",
) -> bool:
"""Refresh persisted display metadata after an explicit settings action."""
return False
@property
def is_running(self) -> bool:
"""Check if the channel is running."""
+24
View File
@@ -0,0 +1,24 @@
"""Small contract shared by channel-owned interactive connection flows."""
from __future__ import annotations
from collections.abc import Mapping
QueryParams = Mapping[str, list[str]]
class ChannelConnectError(Exception):
"""User-facing channel connection failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
def query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
__all__ = ["ChannelConnectError", "QueryParams", "query_first"]
+602
View File
@@ -0,0 +1,602 @@
"""Stable contracts shared by channel runtimes and management surfaces."""
from __future__ import annotations
from collections.abc import Iterable
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal
if TYPE_CHECKING:
from nanobot.channels.plugin import ChannelPlugin
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
RouteFieldType = str | tuple[str, set[str]]
@dataclass(frozen=True, slots=True)
class ChannelValidationContext:
"""Host policy passed to package-owned setup validators."""
allow_local_service_access: bool = False
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
DefaultConfigFactory = Callable[[], dict[str, Any]]
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
InstanceConfigUpdater = Callable[..., dict[str, Any]]
RuntimeNameFactory = Callable[[str, str], str]
FeatureInstancesFactory = Callable[..., list[dict[str, Any]] | None]
LocalStatePresent = Callable[[Any], bool]
__all__ = [
"ChannelActivation",
"ChannelFieldSpec",
"ChannelInstanceSpec",
"ChannelManagementSpec",
"ChannelSetupSpec",
"ChannelValidationContext",
"SetupRequirement",
"channel_feature_instances",
"channel_default_config",
"channel_field_value",
"channel_instance_config",
"channel_instance_specs",
"channel_local_state_present",
"channel_runtime_name",
"resolve_channel_action_target",
"channel_set_config_enabled",
"channel_update_instance_config",
"channel_value_present",
"refresh_channel_feature_metadata",
"stringify_channel_value",
]
_MISSING = object()
@dataclass(frozen=True)
class ChannelActivation:
"""Normalized enablement state used before a channel runtime is imported.
Channel configuration may be a Pydantic model or persisted JSON, and a
channel may expose independently enabled instances. Instance envelopes are
opt-in so a channel can keep using an ``instances``
field as ordinary channel-owned configuration.
"""
enabled: bool | None = None
instances: tuple["ChannelActivation", ...] | None = None
@classmethod
def from_config(
cls,
section: Any,
*,
include_instances: bool = False,
) -> "ChannelActivation":
values = _config_mapping(section)
if values is None:
raw_enabled = getattr(section, "enabled", _MISSING)
return cls(enabled=None if raw_enabled is _MISSING else bool(raw_enabled))
raw_enabled = values.get("enabled", _MISSING)
raw_instances = values.get("instances", _MISSING) if include_instances else _MISSING
instances = (
tuple(
cls.from_config(item, include_instances=True)
for item in raw_instances
if _config_mapping(item) is not None
)
if isinstance(raw_instances, list)
else None
)
return cls(
enabled=None if raw_enabled is _MISSING else bool(raw_enabled),
instances=instances,
)
def resolve(self, *, default: bool = False) -> bool:
"""Return whether the section contains at least one enabled runtime."""
inherited = default if self.enabled is None else self.enabled
if self.instances is None:
return inherited
return any(instance.resolve(default=inherited) for instance in self.instances)
@dataclass(frozen=True)
class ChannelFieldSpec:
"""One channel field exposed through the settings contract."""
kind: FieldKind = "string"
choices: frozenset[str] = frozenset()
default: Any = None
writable: bool = True
snapshot: bool = True
@property
def route_type(self) -> RouteFieldType:
if self.kind == "enum":
return ("enum", set(self.choices))
return self.kind
@dataclass(frozen=True)
class SetupRequirement:
"""A requirement satisfied by any one complete field group."""
alternatives: tuple[tuple[str, ...], ...]
@classmethod
def field(cls, name: str) -> "SetupRequirement":
"""Require one field."""
return cls(((name,),))
@classmethod
def one_of(cls, *alternatives: tuple[str, ...]) -> "SetupRequirement":
"""Require one complete alternative field group."""
return cls(alternatives)
def is_satisfied(self, values: Any) -> bool:
return any(
all(channel_value_present(channel_field_value(values, field)) for field in group)
for group in self.alternatives
)
@property
def simple_field(self) -> str | None:
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
return self.alternatives[0][0]
return None
@dataclass(frozen=True)
class ChannelSetupSpec:
"""Writable setup fields, requirements, and optional validation."""
fields: dict[str, ChannelFieldSpec]
required: tuple[SetupRequirement, ...] = ()
official_url: str | None = None
validator: SetupValidator | None = None
@property
def secrets(self) -> frozenset[str]:
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
@property
def snapshot_fields(self) -> tuple[str, ...]:
return tuple(name for name, field in self.fields.items() if field.snapshot)
@property
def route_field_types(self) -> dict[str, RouteFieldType]:
return {
name: field.route_type
for name, field in self.fields.items()
if field.writable
}
@property
def simple_required_fields(self) -> tuple[str, ...]:
return tuple(
field
for requirement in self.required
if (field := requirement.simple_field) is not None
)
def is_configured(self, values: Any) -> bool:
return bool(self.required) and all(
requirement.is_satisfied(values) for requirement in self.required
)
def to_public_dict(self, channel_name: str) -> dict[str, Any]:
"""Serialize the writable setup contract for generic WebUI consumers."""
simple_required = set(self.simple_required_fields)
fields = []
for name, field in self.fields.items():
if not field.writable:
continue
public_field = {
"key": f"channels.{channel_name}.{name}",
"field": name,
"kind": field.kind,
"choices": sorted(field.choices),
"required": name in simple_required,
}
if field.default is not None:
public_field["default_value"] = stringify_channel_value(field.default)
fields.append(public_field)
payload: dict[str, Any] = {
"fields": fields,
}
if self.official_url:
payload["official_url"] = self.official_url
return payload
@dataclass(frozen=True)
class ChannelInstanceSpec:
"""One independently managed runtime instance."""
instance_id: str
config: Any
@dataclass(frozen=True)
class ChannelManagementSpec:
"""Dependency-free adapter for persisted channel state.
Runtime classes own network and message lifecycle only. A multi-instance
channel supplies these callbacks from a module that can be imported without
its optional platform SDK.
"""
multi_instance: bool = False
default_config: DefaultConfigFactory | None = None
instance_specs: InstanceSpecsFactory | None = None
update_instance_config: InstanceConfigUpdater | None = None
runtime_name: RuntimeNameFactory | None = None
feature_instances: FeatureInstancesFactory | None = None
local_state_present: LocalStatePresent | None = None
def __post_init__(self) -> None:
multi_instance_callbacks = {
"instance_specs": self.instance_specs,
"update_instance_config": self.update_instance_config,
"runtime_name": self.runtime_name,
"feature_instances": self.feature_instances,
}
if not self.multi_instance:
unexpected = [
name for name, callback in multi_instance_callbacks.items() if callback is not None
]
if unexpected:
raise ValueError(
"single-instance channel management cannot define "
+ ", ".join(unexpected)
)
if self.multi_instance and self.instance_specs is None:
raise ValueError("multi-instance channel management requires instance_specs")
if self.multi_instance and self.update_instance_config is None:
raise ValueError("multi-instance channel management requires update_instance_config")
def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
from nanobot.config.loader import merge_missing_defaults
defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
if plugin.setup is not None:
for name, field in plugin.setup.fields.items():
value = field.default
if value is None:
value = {
"string": "",
"secret": "",
"list": [],
"bool": False,
}.get(field.kind, _MISSING)
if value is not _MISSING:
_assign_channel_field(defaults, name, deepcopy(value))
factory = plugin.management.default_config
if factory is None:
return defaults
values = factory()
if not isinstance(values, dict):
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
return merge_missing_defaults(values, defaults)
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
target = values
parts = field.split(".")
for part in parts[:-1]:
nested = target.get(part)
if not isinstance(nested, dict):
nested = {}
target[part] = nested
target = nested
target[parts[-1]] = value
def channel_local_state_present(plugin: ChannelPlugin, section: Any) -> bool:
checker = plugin.management.local_state_present
return bool(checker and checker(section))
def channel_runtime_name(plugin: ChannelPlugin, instance_id: str = "default") -> str:
factory = plugin.management.runtime_name
if factory is None:
if instance_id not in {"", "default"}:
raise ValueError(f"{plugin.name} does not support multiple instances")
runtime_name = plugin.name
else:
runtime_name = str(factory(plugin.name, instance_id))
_validate_runtime_name(plugin, runtime_name)
return runtime_name
def channel_instance_specs(
plugin: ChannelPlugin,
section: Any,
*,
enabled_only: bool = True,
) -> list[ChannelInstanceSpec]:
"""Expand persisted config through the dependency-free management adapter."""
factory = plugin.management.instance_specs
if factory is None:
activation = ChannelActivation.from_config(section)
raw_specs: Iterable[ChannelInstanceSpec] = (
[]
if enabled_only and not activation.resolve(default=plugin.default_enabled)
else [ChannelInstanceSpec(instance_id="default", config=section)]
)
else:
raw_specs = factory(section, enabled_only=enabled_only)
if not isinstance(raw_specs, Iterable):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
)
specs = list(raw_specs)
instance_ids: set[str] = set()
runtime_names: set[str] = set()
for spec in specs:
if not isinstance(spec, ChannelInstanceSpec):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
)
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
)
if spec.instance_id in instance_ids:
raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate instance id "
f"'{spec.instance_id}'"
)
runtime_name = channel_runtime_name(plugin, spec.instance_id)
if runtime_name in runtime_names:
raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned duplicate runtime name "
f"'{runtime_name}'"
)
instance_ids.add(spec.instance_id)
runtime_names.add(runtime_name)
return specs
def resolve_channel_action_target(
requested_instance_id: str | None,
) -> str:
"""Resolve a feature action to an explicit or default instance."""
return (requested_instance_id or "").strip() or "default"
def channel_instance_config(
plugin: ChannelPlugin,
section: Any,
*,
instance_id: str = "default",
) -> dict[str, Any]:
"""Return editable config for one instance."""
selected = next(
(
spec
for spec in channel_instance_specs(plugin, section, enabled_only=False)
if spec.instance_id == instance_id
),
None,
)
if selected is None:
return {}
config = selected.config
if hasattr(config, "model_dump"):
return dict(config.model_dump(mode="json", by_alias=True))
return dict(config) if isinstance(config, dict) else {}
def channel_update_instance_config(
plugin: ChannelPlugin,
section: Any,
values: dict[str, Any],
*,
instance_id: str = "default",
) -> dict[str, Any]:
updater = plugin.management.update_instance_config
if updater is None:
if instance_id not in {"", "default"}:
raise ValueError(f"{plugin.name} does not support multiple instances")
return values
return updater(section, values, instance_id=instance_id)
def channel_set_config_enabled(
plugin: ChannelPlugin,
section: Any,
enabled: bool,
*,
instance_id: str = "default",
) -> dict[str, Any]:
"""Toggle one instance while preserving channel-owned config shape."""
from nanobot.config.loader import merge_missing_defaults
values = channel_instance_config(plugin, section, instance_id=instance_id)
values = merge_missing_defaults(values, channel_default_config(plugin))
values["enabled"] = enabled
return channel_update_instance_config(
plugin,
section,
values,
instance_id=instance_id,
)
def channel_feature_instances(
plugin: ChannelPlugin,
section: Any,
*,
setup_spec: ChannelSetupSpec | None = None,
) -> list[dict[str, Any]] | None:
factory = plugin.management.feature_instances
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None
if overrides is None and not plugin.management.multi_instance:
return None
if overrides is not None and (
not isinstance(overrides, list)
or any(not isinstance(instance, dict) for instance in overrides)
):
raise TypeError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
"must return a list of dicts or None"
)
enabled_ids = {
spec.instance_id for spec in channel_instance_specs(plugin, section, enabled_only=True)
}
instances = [
_channel_feature_instance(
plugin.name,
spec,
setup_spec,
enabled=spec.instance_id in enabled_ids,
)
for spec in channel_instance_specs(plugin, section, enabled_only=False)
]
if overrides is None:
return instances
by_id = {instance["id"]: instance for instance in instances}
seen: set[str] = set()
for override in overrides:
instance_id = override.get("id")
if not isinstance(instance_id, str) or instance_id not in by_id:
raise ValueError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
"returned unknown instance id "
f"'{instance_id}'"
)
if instance_id in seen:
raise ValueError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
"returned duplicate instance id "
f"'{instance_id}'"
)
seen.add(instance_id)
for field in ("name", "display_name", "avatar_url"):
if field in override:
by_id[instance_id][field] = str(override[field] or "")
return instances
def refresh_channel_feature_metadata(
channel_cls: type[Any],
config_path: Path,
*,
instance_id: str = "default",
) -> bool:
return bool(channel_cls.refresh_feature_metadata(config_path, instance_id=instance_id))
def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
channel_name = str(plugin.name).strip()
if not channel_name:
raise ValueError("ChannelPlugin.name must not be empty")
if not isinstance(runtime_name, str) or not runtime_name.strip():
raise ValueError(f"ChannelPlugin.management for '{plugin.name}' returned an empty runtime name")
if runtime_name != channel_name and not runtime_name.startswith(f"{channel_name}."):
raise ValueError(
f"ChannelPlugin.management runtime name '{runtime_name}' must be scoped under "
f"'{channel_name}'"
)
def channel_field_value(values: Any, field_path: str) -> Any:
current = values
for part in field_path.split("."):
candidates = (part, _camel_to_snake(part))
if isinstance(current, dict):
for candidate in candidates:
if candidate in current:
current = current[candidate]
break
else:
return None
continue
for candidate in candidates:
if hasattr(current, candidate):
current = getattr(current, candidate)
break
else:
return None
return current
def channel_value_present(value: Any) -> bool:
return value not in (None, "", [], {})
def stringify_channel_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
def _channel_feature_instance(
channel_name: str,
instance: ChannelInstanceSpec,
setup_spec: ChannelSetupSpec | None,
*,
enabled: bool,
) -> dict[str, Any]:
config = instance.config
name = str(channel_field_value(config, "name") or instance.instance_id).strip()
display_name = str(channel_field_value(config, "displayName") or name).strip()
avatar_url = str(channel_field_value(config, "avatarUrl") or "").strip()
config_values: dict[str, str] = {}
configured_fields: list[str] = []
setup_fields = setup_spec.fields.items() if setup_spec else ()
for field_name, field_spec in setup_fields:
if not field_spec.writable:
continue
value = channel_field_value(config, field_name)
if not channel_value_present(value):
continue
key = f"channels.{channel_name}.{field_name}"
configured_fields.append(key)
if field_spec.kind != "secret":
config_values[key] = stringify_channel_value(value)
return {
"id": instance.instance_id,
"name": name,
"display_name": display_name,
"avatar_url": avatar_url,
"enabled": enabled,
"configured": bool(setup_spec and setup_spec.is_configured(config)),
"config_values": config_values,
"configured_fields": configured_fields,
}
def _config_mapping(value: Any) -> dict[str, Any] | None:
if hasattr(value, "model_dump"):
dumped = value.model_dump(mode="json", by_alias=True)
return dumped if isinstance(dumped, dict) else None
return value if isinstance(value, dict) else None
def _camel_to_snake(value: str) -> str:
chars: list[str] = []
for char in value:
if char.isupper():
if chars:
chars.append("_")
chars.append(char.lower())
else:
chars.append(char)
return "".join(chars)
+1
View File
@@ -0,0 +1 @@
"""DingTalk channel package."""
+24
View File
@@ -0,0 +1,24 @@
"""DingTalk management contract."""
from nanobot.channels._manifest import field, required_fields
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"clientId": field(),
"clientSecret": field("secret"),
"allowFrom": field("list"),
},
required=required_fields("clientId", "clientSecret"),
official_url="https://open.dingtalk.com/",
)
PLUGIN = ChannelPlugin(
name="dingtalk",
display_name="DingTalk",
runtime=f"{__package__}.runtime:DingTalkChannel",
setup=SETUP_SPEC,
dependencies=("dingtalk-stream>=0.24.0,<1.0.0",),
webui="webui/index.ts",
)
@@ -710,10 +710,11 @@ class DingTalkChannel(BaseChannel):
"""Send a message through DingTalk."""
token = await self._get_access_token()
if not token:
return
raise RuntimeError("DingTalk access token unavailable")
if msg.content and msg.content.strip():
await self._send_markdown_text(token, msg.chat_id, msg.content.strip())
if not await self._send_markdown_text(token, msg.chat_id, msg.content.strip()):
raise RuntimeError("DingTalk text message was not delivered")
for media_ref in msg.media or []:
ok = await self._send_media_ref(token, msg.chat_id, media_ref)
@@ -722,11 +723,12 @@ class DingTalkChannel(BaseChannel):
self.logger.error("media send failed for {}", media_ref)
# Send visible fallback so failures are observable by the user.
filename = self._guess_filename(media_ref, self._guess_upload_type(media_ref))
await self._send_markdown_text(
if not await self._send_markdown_text(
token,
msg.chat_id,
f"[Attachment send failed: {filename}]",
)
):
raise RuntimeError("DingTalk attachment fallback was not delivered")
async def _on_message(
self,
@@ -0,0 +1 @@
"""Tests for the DingTalk channel package."""
@@ -2,6 +2,7 @@ import asyncio
import zipfile
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
@@ -16,9 +17,14 @@ except ImportError:
if not DINGTALK_AVAILABLE:
pytest.skip("DingTalk dependencies not installed (dingtalk-stream)", allow_module_level=True)
import nanobot.channels.dingtalk as dingtalk_module
import nanobot.channels.dingtalk.runtime as dingtalk_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.dingtalk import DingTalkChannel, DingTalkConfig, NanobotDingTalkHandler
from nanobot.channels.dingtalk.runtime import (
DingTalkChannel,
DingTalkConfig,
NanobotDingTalkHandler,
)
class _FakeResponse:
@@ -864,6 +870,35 @@ async def test_send_batch_message_returns_false_on_api_error() -> None:
assert result is True
@pytest.mark.asyncio
async def test_send_raises_when_access_token_is_unavailable(monkeypatch) -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value=None))
with pytest.raises(RuntimeError, match="access token unavailable"):
await channel.send(
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
)
@pytest.mark.asyncio
async def test_send_raises_when_text_is_not_delivered(monkeypatch) -> None:
channel = DingTalkChannel(
DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]),
MessageBus(),
)
monkeypatch.setattr(channel, "_get_access_token", AsyncMock(return_value="token"))
monkeypatch.setattr(channel, "_send_markdown_text", AsyncMock(return_value=False))
with pytest.raises(RuntimeError, match="text message was not delivered"):
await channel.send(
OutboundMessage(channel="dingtalk", chat_id="user123", content="hello")
)
@pytest.mark.asyncio
async def test_send_media_ref_short_circuits_on_transport_error() -> None:
"""When the first send fails with a transport error, _send_media_ref must
@@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import save_config
from nanobot.config.schema import Config
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{
"channels": {
"dingtalk": {
"clientId": "ding-client",
"clientSecret": "ding-secret",
}
}
}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
result = validate_channel_config("dingtalk", {})
assert result["status"] == "configured"
assert result["can_enable"] is True
assert any(check["status"] == "skipped" for check in result["checks"])
+21
View File
@@ -0,0 +1,21 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
export default {
presentation: {
displayName: "DingTalk",
initials: "DT",
color: "#1677FF",
logoUrl:
"https://img.alicdn.com/imgextra/i3/O1CN01WMvMRG1ks3Ixc9x1v_!!6000000004738-55-tps-32-32.svg",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("dingtalk"),
fields: [
{ key: "channels.dingtalk.clientId" },
{ key: "channels.dingtalk.clientSecret" },
{ key: "channels.dingtalk.allowFrom" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,31 @@
{
"description": "Use nanobot from DingTalk groups.",
"requirements": "DingTalk app credentials and gateway",
"setup": {
"docsLabel": "Open DingTalk setup",
"officialLabel": "Open DingTalk console",
"tryIt": "Send a test message from the DingTalk group where the app is installed.",
"summary": "DingTalk needs app credentials from Stream mode.",
"steps": [
"Create or choose a DingTalk app with Stream mode enabled.",
"Add Client ID and Client Secret.",
"Save and enable DingTalk, then send a test message."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "DingTalk client ID",
"help": "Copy it from DingTalk app credentials."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Copy it from the same DingTalk app credentials page."
},
"allowFrom": {
"label": "Allowed users",
"placeholder": "User IDs, comma separated"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Usa nanobot desde grupos de DingTalk.",
"requirements": "Credenciales de la app de DingTalk y gateway",
"setup": {
"docsLabel": "Abrir guía de DingTalk",
"officialLabel": "Abrir consola de DingTalk",
"tryIt": "Envía un mensaje de prueba desde el grupo de DingTalk donde está instalada la app.",
"summary": "DingTalk necesita credenciales de una app en modo Stream.",
"steps": [
"Crea o elige una app de DingTalk con el modo Stream activado.",
"Añade el Client ID y el Client Secret.",
"Guarda y activa DingTalk; después envía un mensaje de prueba."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID de DingTalk",
"help": "Cópialo de las credenciales de la app de DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Cópialo de la misma página de credenciales."
},
"allowFrom": {
"label": "Usuarios permitidos",
"placeholder": "ID de usuario separados por comas"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Utilisez nanobot depuis les groupes DingTalk.",
"requirements": "Identifiants dapplication DingTalk et passerelle",
"setup": {
"docsLabel": "Ouvrir le guide DingTalk",
"officialLabel": "Ouvrir la console DingTalk",
"tryIt": "Envoyez un message test dans le groupe DingTalk où lapplication est installée.",
"summary": "DingTalk nécessite les identifiants dune 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 lapplication DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Copiez-le depuis la même page didentifiants DingTalk."
},
"allowFrom": {
"label": "Utilisateurs autorisés",
"placeholder": "ID utilisateur séparés par des virgules"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Gunakan nanobot dari grup DingTalk.",
"requirements": "Kredensial aplikasi DingTalk dan gateway",
"setup": {
"docsLabel": "Buka panduan DingTalk",
"officialLabel": "Buka konsol DingTalk",
"tryIt": "Kirim pesan uji dari grup DingTalk tempat aplikasi dipasang.",
"summary": "DingTalk memerlukan kredensial aplikasi dari mode Stream.",
"steps": [
"Buat atau pilih aplikasi DingTalk dengan mode Stream aktif.",
"Tambahkan Client ID dan Client Secret.",
"Simpan dan aktifkan DingTalk, lalu kirim pesan uji."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID DingTalk",
"help": "Salin dari kredensial aplikasi DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Salin dari halaman kredensial yang sama."
},
"allowFrom": {
"label": "Pengguna yang diizinkan",
"placeholder": "ID pengguna, dipisahkan koma"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "DingTalk グループから nanobot を利用します。",
"requirements": "DingTalk アプリの認証情報とゲートウェイ",
"setup": {
"docsLabel": "DingTalk 設定ガイドを開く",
"officialLabel": "DingTalk コンソールを開く",
"tryIt": "アプリをインストールした DingTalk グループからテストメッセージを送信します。",
"summary": "DingTalk には Stream モードのアプリ認証情報が必要です。",
"steps": [
"Stream モードを有効にした DingTalk アプリを作成または選択します。",
"Client ID と Client Secret を追加します。",
"保存して DingTalk を有効にし、テストメッセージを送信します。"
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "DingTalk Client ID",
"help": "DingTalk アプリの認証情報からコピーします。"
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "同じ認証情報ページからコピーします。"
},
"allowFrom": {
"label": "許可するユーザー",
"placeholder": "ユーザー ID(カンマ区切り)"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "DingTalk 그룹에서 nanobot을 사용합니다.",
"requirements": "DingTalk 앱 자격 증명 및 게이트웨이",
"setup": {
"docsLabel": "DingTalk 설정 가이드 열기",
"officialLabel": "DingTalk 콘솔 열기",
"tryIt": "앱이 설치된 DingTalk 그룹에서 테스트 메시지를 보내세요.",
"summary": "DingTalk에는 Stream 모드 앱 자격 증명이 필요합니다.",
"steps": [
"Stream 모드가 활성화된 DingTalk 앱을 만들거나 선택하세요.",
"Client ID와 Client Secret을 추가하세요.",
"저장하고 DingTalk을 활성화한 다음 테스트 메시지를 보내세요."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "DingTalk Client ID",
"help": "DingTalk 앱 자격 증명에서 복사하세요."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "같은 자격 증명 페이지에서 복사하세요."
},
"allowFrom": {
"label": "허용된 사용자",
"placeholder": "사용자 ID, 쉼표로 구분"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Use o nanobot em grupos do DingTalk.",
"requirements": "Credenciais do app DingTalk e gateway",
"setup": {
"docsLabel": "Abrir guia do DingTalk",
"officialLabel": "Abrir console do DingTalk",
"tryIt": "Envie uma mensagem de teste no grupo do DingTalk onde o app está instalado.",
"summary": "O DingTalk precisa das credenciais de um app no modo Stream.",
"steps": [
"Crie ou escolha um app do DingTalk com o modo Stream ativado.",
"Adicione o Client ID e o Client Secret.",
"Salve e ative o DingTalk; depois, envie uma mensagem de teste."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID do DingTalk",
"help": "Copie das credenciais do app DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Copie da mesma página de credenciais."
},
"allowFrom": {
"label": "Usuários permitidos",
"placeholder": "IDs de usuário separados por vírgulas"
}
}
}
}
@@ -0,0 +1,31 @@
{
"description": "Sử dụng nanobot trong các nhóm DingTalk.",
"requirements": "Thông tin xác thực ứng dụng DingTalk và gateway",
"setup": {
"docsLabel": "Mở hướng dẫn DingTalk",
"officialLabel": "Mở bảng điều khiển DingTalk",
"tryIt": "Gửi tin nhắn thử từ nhóm DingTalk đã cài ứng dụng.",
"summary": "DingTalk cần thông tin xác thực ứng dụng ở chế độ Stream.",
"steps": [
"Tạo hoặc chọn ứng dụng DingTalk đã bật chế độ Stream.",
"Thêm Client ID và Client Secret.",
"Lưu và bật DingTalk, sau đó gửi tin nhắn thử."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Client ID DingTalk",
"help": "Sao chép từ thông tin xác thực ứng dụng DingTalk."
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "Sao chép từ cùng trang thông tin xác thực."
},
"allowFrom": {
"label": "Người dùng được phép",
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
}
}
}
}
@@ -0,0 +1,32 @@
{
"displayName": "钉钉",
"description": "在钉钉群中使用 nanobot。",
"requirements": "钉钉应用凭据和网关",
"setup": {
"docsLabel": "打开钉钉配置指南",
"officialLabel": "打开钉钉开发者后台",
"tryIt": "在已安装应用的钉钉群中发送一条测试消息。",
"summary": "钉钉需要 Stream 模式的应用凭据。",
"steps": [
"创建或选择一个已启用 Stream 模式的钉钉应用。",
"填写 Client ID 和 Client Secret。",
"保存并启用钉钉,然后发送一条测试消息。"
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "钉钉 Client ID",
"help": "从钉钉应用凭据页面复制。"
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "从同一个钉钉应用凭据页面复制。"
},
"allowFrom": {
"label": "允许的用户",
"placeholder": "用户 ID,用逗号分隔"
}
}
}
}
@@ -0,0 +1,32 @@
{
"displayName": "釘釘",
"description": "在釘釘群組中使用 nanobot。",
"requirements": "釘釘應用程式憑證和閘道",
"setup": {
"docsLabel": "開啟釘釘設定指南",
"officialLabel": "開啟釘釘開發者後台",
"tryIt": "在已安裝應用程式的釘釘群組中傳送一則測試訊息。",
"summary": "釘釘需要 Stream 模式的應用程式憑證。",
"steps": [
"建立或選擇一個已啟用 Stream 模式的釘釘應用程式。",
"填入 Client ID 和 Client Secret。",
"儲存並啟用釘釘,然後傳送一則測試訊息。"
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "釘釘 Client ID",
"help": "從釘釘應用程式憑證頁面複製。"
},
"clientSecret": {
"label": "Client Secret",
"placeholder": "••••••",
"help": "從同一個釘釘應用程式憑證頁面複製。"
},
"allowFrom": {
"label": "允許的使用者",
"placeholder": "使用者 ID,以逗號分隔"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Discord channel package."""
+27
View File
@@ -0,0 +1,27 @@
"""Discord management contract."""
from nanobot.channels._manifest import DIRECT_GROUP_POLICIES, field, required
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.discord.validation import validate
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"token": field("secret"),
"allowFrom": field("list", snapshot=False),
"allowChannels": field("list"),
"groupPolicy": field("enum", choices=DIRECT_GROUP_POLICIES, default="mention"),
},
required=(required("token"),),
official_url="https://discord.com/developers/applications",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="discord",
display_name="Discord",
runtime=f"{__package__}.runtime:DiscordChannel",
setup=SETUP_SPEC,
dependencies=("discord.py>=2.5.2,<3.0.0",),
webui="webui/index.ts",
)
@@ -264,7 +264,7 @@ if DISCORD_AVAILABLE:
channel = await self.fetch_channel(channel_id)
except Exception as e:
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
return
raise
reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
sent_media = False
@@ -466,8 +466,7 @@ class DiscordChannel(BaseChannel):
"""Send a message through Discord using discord.py."""
client = self._client
if client is None or not client.is_ready():
self.logger.warning("client not ready; dropping outbound message")
return
raise RuntimeError("Discord client is not ready")
is_progress = isinstance(msg.event, ProgressEvent)
@@ -0,0 +1 @@
"""Tests for the Discord channel package."""
@@ -1,3 +1,5 @@
# ruff: noqa: E402
from __future__ import annotations
import asyncio
@@ -12,7 +14,7 @@ import discord
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.discord import (
from nanobot.channels.discord.runtime import (
MAX_MESSAGE_LEN,
DiscordBotClient,
DiscordChannel,
@@ -230,7 +232,7 @@ async def test_start_returns_when_discord_dependency_missing(monkeypatch) -> Non
DiscordConfig(enabled=True, token="token", allow_from=["*"]),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DISCORD_AVAILABLE", False)
monkeypatch.setattr("nanobot.channels.discord.runtime.DISCORD_AVAILABLE", False)
await channel.start()
@@ -249,7 +251,7 @@ async def test_start_handles_client_construction_failure(monkeypatch) -> None:
def _boom(owner, *, intents, proxy=None, proxy_auth=None):
raise RuntimeError("bad client")
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _boom)
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _boom)
await channel.start()
@@ -267,7 +269,7 @@ async def test_start_handles_client_start_failure(monkeypatch) -> None:
_FakeDiscordClient.instances.clear()
_FakeDiscordClient.start_error = RuntimeError("connect failed")
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
await channel.start()
@@ -620,7 +622,7 @@ async def test_on_message_downloads_attachments(tmp_path, monkeypatch) -> None:
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
monkeypatch.setattr("nanobot.channels.discord.runtime.get_media_dir", lambda _name: tmp_path)
await channel._on_message(
_make_message(
@@ -644,7 +646,7 @@ async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch
handled.append(kwargs)
channel._handle_message = capture_handle # type: ignore[method-assign]
monkeypatch.setattr("nanobot.channels.discord.get_media_dir", lambda _name: tmp_path)
monkeypatch.setattr("nanobot.channels.discord.runtime.get_media_dir", lambda _name: tmp_path)
await channel._on_message(
_make_message(
@@ -659,18 +661,19 @@ async def test_on_message_marks_failed_attachment_download(tmp_path, monkeypatch
@pytest.mark.asyncio
async def test_send_warns_when_client_not_ready() -> None:
# Sending without a running/ready client should be a safe no-op.
async def test_send_raises_when_client_not_ready() -> None:
# The manager must be able to retry while Discord is still connecting.
channel = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
with pytest.raises(RuntimeError, match="client is not ready"):
await channel.send(OutboundMessage(channel="discord", chat_id="123", content="hello"))
assert channel._typing_tasks == {}
@pytest.mark.asyncio
async def test_send_skips_when_channel_not_cached() -> None:
# Outbound sends should be skipped when the destination channel is not resolvable.
async def test_send_raises_when_channel_cannot_be_resolved() -> None:
# The manager must be able to retry transient channel-resolution failures.
owner = DiscordChannel(DiscordConfig(enabled=True, allow_from=["*"]), MessageBus())
client = DiscordBotClient(owner, intents=discord.Intents.none())
fetch_calls: list[int] = []
@@ -681,7 +684,10 @@ async def test_send_skips_when_channel_not_cached() -> None:
client.fetch_channel = fetch_channel # type: ignore[method-assign]
await client.send_outbound(OutboundMessage(channel="discord", chat_id="123", content="hello"))
with pytest.raises(RuntimeError, match="not found"):
await client.send_outbound(
OutboundMessage(channel="discord", chat_id="123", content="hello")
)
assert client.get_channel(123) is None
assert fetch_calls == [123]
@@ -737,7 +743,7 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
client.channels[123] = target
times = iter([1.0, 3.0, 5.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
monkeypatch.setattr("nanobot.channels.discord.runtime.time.monotonic", lambda: next(times, 5.0))
await owner.send_delta("123", "hel", stream_id="s1")
await owner.send_delta("123", "lo", stream_id="s1")
@@ -764,7 +770,7 @@ async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None
assert len(chunks) == 2
times = iter([1.0, 3.0])
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
monkeypatch.setattr("nanobot.channels.discord.runtime.time.monotonic", lambda: next(times, 3.0))
await owner.send_delta("123", prefix, stream_id="s1")
await owner.send_delta("123", suffix, stream_id="s1")
@@ -1209,7 +1215,7 @@ async def test_start_passes_proxy_to_client(monkeypatch) -> None:
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
await channel.start()
@@ -1234,7 +1240,7 @@ async def test_start_passes_proxy_auth_when_credentials_provided(monkeypatch) ->
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
await channel.start()
@@ -1260,7 +1266,7 @@ async def test_start_no_proxy_auth_when_only_username(monkeypatch) -> None:
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
await channel.start()
@@ -1281,7 +1287,7 @@ async def test_start_no_proxy_auth_when_only_password(monkeypatch) -> None:
),
MessageBus(),
)
monkeypatch.setattr("nanobot.channels.discord.DiscordBotClient", _FakeDiscordClient)
monkeypatch.setattr("nanobot.channels.discord.runtime.DiscordBotClient", _FakeDiscordClient)
await channel.start()
+69
View File
@@ -0,0 +1,69 @@
"""Discord setup validation owned by the channel package."""
from typing import Any
import httpx
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import (
check,
http_get,
payload,
required_checks,
status_from_checks,
string_value,
)
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
checks, missing = required_checks("discord", values)
token = string_value(values.get("token"))
if token:
try:
data = http_get(
"https://discord.com/api/v10/users/@me",
headers={"Authorization": f"Bot {token}"},
)
bot_id = str(data.get("id") or "")
checks.append(check("bot_token", "Bot token", "pass", "Discord accepted the bot token."))
identity = {
"name": data.get("global_name") or data.get("username"),
"account": bot_id,
}
if bot_id:
checks.append(
check(
"invite",
"Server invite",
"pass",
"Use this generated OAuth URL to invite the bot.",
action_url=(
"https://discord.com/oauth2/authorize"
f"?client_id={bot_id}&scope=bot%20applications.commands"
),
)
)
return payload(
"discord",
"connected",
checks,
identity=identity,
missing_fields=missing,
)
except httpx.HTTPStatusError as exc:
checks.append(
check(
"bot_token",
"Bot token",
"fail",
f"Discord rejected the token: HTTP {exc.response.status_code}",
)
)
except Exception as exc:
checks.append(
check("bot_token", "Bot token", "warn", f"Could not reach Discord now: {exc}")
)
return status_from_checks("discord", checks, missing)
__all__ = ["validate"]
+20
View File
@@ -0,0 +1,20 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
export default {
presentation: {
displayName: "Discord",
initials: "DC",
color: "#5865F2",
logoUrl: "https://discord.com/favicon.ico",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("discord"),
fields: [
{ key: "channels.discord.token" },
{ key: "channels.discord.allowChannels" },
{ key: "channels.discord.groupPolicy" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,39 @@
{
"description": "Use nanobot from Discord servers and DMs.",
"requirements": "Discord bot token, permissions, gateway",
"setup": {
"docsLabel": "Open Discord setup",
"officialLabel": "Open Discord portal",
"tryIt": "Mention the bot in a server or send it a direct message.",
"summary": "Enable turns on Discord support. Discord still needs a bot token and server permissions.",
"steps": [
"Create a bot in Discord Developer Portal and copy its token.",
"Invite the bot to your server with message read/send and slash command permissions.",
"Save and enable Discord, then mention the bot or send a direct message."
],
"fields": {
"token": {
"label": "Bot token",
"placeholder": "Discord bot token",
"help": "Create it from the Bot page in Discord Developer Portal."
},
"allowChannels": {
"label": "Allowed channels",
"placeholder": "Channel IDs, comma separated",
"help": "Leave empty to allow any channel the bot can read."
},
"groupPolicy": {
"label": "Group behavior",
"choices": {
"mention": "Mention only",
"open": "All messages",
"allowlist": "Allowlist"
}
},
"allowFrom": {
"label": "Allowed users",
"placeholder": "User IDs, comma separated"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Usa nanobot en servidores y mensajes directos de Discord.",
"requirements": "Token del bot de Discord, permisos y gateway",
"setup": {
"docsLabel": "Abrir guía de Discord",
"officialLabel": "Abrir portal de Discord",
"tryIt": "Menciona al bot en un servidor o envíale un mensaje directo.",
"summary": "Activar habilita Discord. Aún necesitas el token del bot y permisos del servidor.",
"steps": [
"Crea un bot en Discord Developer Portal y copia su token.",
"Invítalo al servidor con permisos para leer/enviar mensajes y usar comandos slash.",
"Guarda y activa Discord; después menciona al bot o envíale un mensaje directo."
],
"fields": {
"token": {
"label": "Token del bot",
"placeholder": "Token del bot de Discord",
"help": "Créalo desde la página Bot de Discord Developer Portal."
},
"allowChannels": {
"label": "Canales permitidos",
"placeholder": "ID de canal separados por comas",
"help": "Déjalo vacío para permitir cualquier canal que el bot pueda leer."
},
"groupPolicy": {
"label": "Comportamiento en grupos",
"choices": {
"mention": "Solo menciones",
"open": "Todos los mensajes",
"allowlist": "Lista permitida"
}
},
"allowFrom": {
"label": "Usuarios permitidos",
"placeholder": "ID de usuario separados por comas"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Utilisez nanobot sur les serveurs Discord et en messages privés.",
"requirements": "Jeton du bot Discord, permissions et passerelle",
"setup": {
"docsLabel": "Ouvrir le guide Discord",
"officialLabel": "Ouvrir le portail Discord",
"tryIt": "Mentionnez le bot sur un serveur ou envoyez-lui un message privé.",
"summary": "Lactivation 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, denvoi 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 dautorisation"
}
},
"allowFrom": {
"label": "Utilisateurs autorisés",
"placeholder": "ID utilisateur séparés par des virgules"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Gunakan nanobot dari server dan DM Discord.",
"requirements": "Token bot Discord, izin, dan gateway",
"setup": {
"docsLabel": "Buka panduan Discord",
"officialLabel": "Buka portal Discord",
"tryIt": "Sebut bot di server atau kirim pesan langsung.",
"summary": "Mengaktifkan akan menyalakan dukungan Discord. Token bot dan izin server tetap diperlukan.",
"steps": [
"Buat bot di Discord Developer Portal dan salin tokennya.",
"Undang bot ke server dengan izin baca/kirim pesan dan perintah slash.",
"Simpan dan aktifkan Discord, lalu sebut bot atau kirim DM."
],
"fields": {
"token": {
"label": "Token bot",
"placeholder": "Token bot Discord",
"help": "Buat dari halaman Bot di Discord Developer Portal."
},
"allowChannels": {
"label": "Channel yang diizinkan",
"placeholder": "ID channel, dipisahkan koma",
"help": "Kosongkan untuk mengizinkan semua channel yang dapat dibaca bot."
},
"groupPolicy": {
"label": "Perilaku grup",
"choices": {
"mention": "Hanya sebutan",
"open": "Semua pesan",
"allowlist": "Daftar izin"
}
},
"allowFrom": {
"label": "Pengguna yang diizinkan",
"placeholder": "ID pengguna, dipisahkan koma"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Discord サーバーと DM から nanobot を利用します。",
"requirements": "Discord ボットトークン、権限、ゲートウェイ",
"setup": {
"docsLabel": "Discord 設定ガイドを開く",
"officialLabel": "Discord ポータルを開く",
"tryIt": "サーバーでボットをメンションするか、DM を送信します。",
"summary": "有効化すると Discord 対応がオンになります。ボットトークンとサーバー権限が必要です。",
"steps": [
"Discord Developer Portal でボットを作成し、トークンをコピーします。",
"メッセージの読み書きとスラッシュコマンド権限を付けてサーバーに招待します。",
"保存して Discord を有効にし、メンションまたは DM を送信します。"
],
"fields": {
"token": {
"label": "ボットトークン",
"placeholder": "Discord ボットトークン",
"help": "Discord Developer Portal の Bot ページで作成します。"
},
"allowChannels": {
"label": "許可するチャンネル",
"placeholder": "チャンネル ID(カンマ区切り)",
"help": "空欄の場合、ボットが読めるすべてのチャンネルを許可します。"
},
"groupPolicy": {
"label": "グループでの動作",
"choices": {
"mention": "メンションのみ",
"open": "すべてのメッセージ",
"allowlist": "許可リスト"
}
},
"allowFrom": {
"label": "許可するユーザー",
"placeholder": "ユーザー ID(カンマ区切り)"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Discord 서버와 DM에서 nanobot을 사용합니다.",
"requirements": "Discord 봇 토큰, 권한 및 게이트웨이",
"setup": {
"docsLabel": "Discord 설정 가이드 열기",
"officialLabel": "Discord 포털 열기",
"tryIt": "서버에서 봇을 멘션하거나 DM을 보내세요.",
"summary": "활성화하면 Discord 지원이 켜집니다. 봇 토큰과 서버 권한이 필요합니다.",
"steps": [
"Discord Developer Portal에서 봇을 만들고 토큰을 복사하세요.",
"메시지 읽기/보내기 및 슬래시 명령 권한으로 서버에 초대하세요.",
"저장하고 Discord를 활성화한 다음 봇을 멘션하거나 DM을 보내세요."
],
"fields": {
"token": {
"label": "봇 토큰",
"placeholder": "Discord 봇 토큰",
"help": "Discord Developer Portal의 Bot 페이지에서 생성하세요."
},
"allowChannels": {
"label": "허용된 채널",
"placeholder": "채널 ID, 쉼표로 구분",
"help": "비워 두면 봇이 읽을 수 있는 모든 채널을 허용합니다."
},
"groupPolicy": {
"label": "그룹 동작",
"choices": {
"mention": "멘션만",
"open": "모든 메시지",
"allowlist": "허용 목록"
}
},
"allowFrom": {
"label": "허용된 사용자",
"placeholder": "사용자 ID, 쉼표로 구분"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Use o nanobot em servidores e DMs do Discord.",
"requirements": "Token do bot Discord, permissões e gateway",
"setup": {
"docsLabel": "Abrir guia do Discord",
"officialLabel": "Abrir portal do Discord",
"tryIt": "Mencione o bot em um servidor ou envie uma mensagem direta.",
"summary": "Ativar liga o suporte ao Discord. O token do bot e as permissões do servidor ainda são necessários.",
"steps": [
"Crie um bot no Discord Developer Portal e copie o token.",
"Convide-o para o servidor com permissões de leitura/envio e comandos slash.",
"Salve e ative o Discord; depois, mencione o bot ou envie uma DM."
],
"fields": {
"token": {
"label": "Token do bot",
"placeholder": "Token do bot Discord",
"help": "Crie-o na página Bot do Discord Developer Portal."
},
"allowChannels": {
"label": "Canais permitidos",
"placeholder": "IDs de canal separados por vírgulas",
"help": "Deixe vazio para permitir qualquer canal que o bot consiga ler."
},
"groupPolicy": {
"label": "Comportamento em grupos",
"choices": {
"mention": "Somente menções",
"open": "Todas as mensagens",
"allowlist": "Lista de permissão"
}
},
"allowFrom": {
"label": "Usuários permitidos",
"placeholder": "IDs de usuário separados por vírgulas"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "Sử dụng nanobot trong máy chủ và tin nhắn riêng Discord.",
"requirements": "Token bot Discord, quyền và gateway",
"setup": {
"docsLabel": "Mở hướng dẫn Discord",
"officialLabel": "Mở cổng Discord",
"tryIt": "Nhắc bot trong máy chủ hoặc gửi tin nhắn riêng.",
"summary": "Bật sẽ kích hoạt hỗ trợ Discord. Bạn vẫn cần token bot và quyền trên máy chủ.",
"steps": [
"Tạo bot trong Discord Developer Portal và sao chép token.",
"Mời bot vào máy chủ với quyền đọc/gửi tin nhắn và lệnh slash.",
"Lưu và bật Discord, sau đó nhắc bot hoặc gửi tin nhắn riêng."
],
"fields": {
"token": {
"label": "Token bot",
"placeholder": "Token bot Discord",
"help": "Tạo từ trang Bot trong Discord Developer Portal."
},
"allowChannels": {
"label": "Kênh được phép",
"placeholder": "ID kênh, phân tách bằng dấu phẩy",
"help": "Để trống để cho phép mọi kênh bot có thể đọc."
},
"groupPolicy": {
"label": "Hành vi trong nhóm",
"choices": {
"mention": "Chỉ khi được nhắc",
"open": "Mọi tin nhắn",
"allowlist": "Danh sách cho phép"
}
},
"allowFrom": {
"label": "Người dùng được phép",
"placeholder": "ID người dùng, phân tách bằng dấu phẩy"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "在 Discord 服务器和私信中使用 nanobot。",
"requirements": "Discord 机器人令牌、权限和网关",
"setup": {
"docsLabel": "打开 Discord 配置指南",
"officialLabel": "打开 Discord 开发者后台",
"tryIt": "在服务器中提及机器人,或向它发送私信。",
"summary": "启用只会打开 Discord 支持;还需要机器人令牌和服务器权限。",
"steps": [
"在 Discord Developer Portal 中创建机器人并复制令牌。",
"将机器人邀请到服务器,并授予读取/发送消息及斜杠命令权限。",
"保存并启用 Discord,然后提及机器人或发送私信。"
],
"fields": {
"token": {
"label": "机器人令牌",
"placeholder": "Discord 机器人令牌",
"help": "从 Discord Developer Portal 的 Bot 页面创建。"
},
"allowChannels": {
"label": "允许的频道",
"placeholder": "频道 ID,用逗号分隔",
"help": "留空则允许机器人可读取的所有频道。"
},
"groupPolicy": {
"label": "群组行为",
"choices": {
"mention": "仅提及时",
"open": "所有消息",
"allowlist": "白名单"
}
},
"allowFrom": {
"label": "允许的用户",
"placeholder": "用户 ID,用逗号分隔"
}
}
}
}
@@ -0,0 +1,39 @@
{
"description": "在 Discord 伺服器和私訊中使用 nanobot。",
"requirements": "Discord 機器人權杖、權限和閘道",
"setup": {
"docsLabel": "開啟 Discord 設定指南",
"officialLabel": "開啟 Discord 開發者後台",
"tryIt": "在伺服器中提及機器人,或向它傳送私訊。",
"summary": "啟用只會開啟 Discord 支援;還需要機器人權杖和伺服器權限。",
"steps": [
"在 Discord Developer Portal 中建立機器人並複製權杖。",
"將機器人邀請到伺服器,並授予讀取/傳送訊息及斜線指令權限。",
"儲存並啟用 Discord,然後提及機器人或傳送私訊。"
],
"fields": {
"token": {
"label": "機器人權杖",
"placeholder": "Discord 機器人權杖",
"help": "從 Discord Developer Portal 的 Bot 頁面建立。"
},
"allowChannels": {
"label": "允許的頻道",
"placeholder": "頻道 ID,以逗號分隔",
"help": "留空則允許機器人可讀取的所有頻道。"
},
"groupPolicy": {
"label": "群組行為",
"choices": {
"mention": "僅提及時",
"open": "所有訊息",
"allowlist": "允許清單"
}
},
"allowFrom": {
"label": "允許的使用者",
"placeholder": "使用者 ID,以逗號分隔"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Email channel package."""
+44
View File
@@ -0,0 +1,44 @@
"""Email management contract."""
from nanobot.channels._manifest import field, required_fields
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.email.validation import validate
from nanobot.channels.plugin import ChannelPlugin
SETUP_SPEC = ChannelSetupSpec(
fields={
"consentGranted": field("bool", default=False),
"imapHost": field(),
"imapPort": field("int", default=993),
"imapUsername": field(),
"imapPassword": field("secret"),
"smtpHost": field(),
"smtpPort": field("int", default=587),
"smtpUsername": field(),
"smtpPassword": field("secret"),
"fromAddress": field(),
"pollIntervalSeconds": field("int", default=30),
"allowFrom": field("list"),
"verifyDkim": field("bool", default=True),
"verifySpf": field("bool", default=True),
},
required=required_fields(
"consentGranted",
"imapHost",
"imapUsername",
"imapPassword",
"smtpHost",
"smtpUsername",
"smtpPassword",
),
official_url="https://support.google.com/accounts/answer/185833",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="email",
display_name="Email",
runtime=f"{__package__}.runtime:EmailChannel",
setup=SETUP_SPEC,
webui="webui/index.ts",
)
+1
View File
@@ -0,0 +1 @@
"""Tests for the email channel package."""
@@ -8,7 +8,7 @@ import pytest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels.email import EmailChannel, EmailConfig
from nanobot.channels.email.runtime import EmailChannel, EmailConfig
def _make_config(**overrides) -> EmailConfig:
@@ -77,7 +77,7 @@ def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(), MessageBus())
items, skipped_uids = channel._fetch_new_messages()
@@ -117,7 +117,7 @@ def test_fetch_new_messages_returns_accepted_and_skipped_uids(monkeypatch) -> No
def logout(self):
return "BYE", [b""]
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
items, skipped_uids = channel._fetch_new_messages()
@@ -149,7 +149,7 @@ def test_fetch_new_messages_rejected_returns_skipped_uid(monkeypatch) -> None:
def logout(self):
return "BYE", [b""]
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FakeIMAP())
channel_skip = EmailChannel(
_make_config(from_address="bot@example.com", post_action="delete", post_action_ignore_skipped=True),
@@ -214,7 +214,7 @@ def test_apply_post_actions_batch_delete_uses_one_connection(monkeypatch) -> Non
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
channel._apply_post_actions_batch(["123", "124"])
@@ -271,7 +271,7 @@ def test_apply_post_actions_batch_move_copies_then_deletes(monkeypatch) -> None:
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(
_make_config(post_action="move", post_action_move_mailbox="Processed"),
@@ -312,7 +312,7 @@ def test_apply_post_actions_batch_move_prefers_uid_move_when_supported(monkeypat
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(
_make_config(post_action="move", post_action_move_mailbox="Processed"),
@@ -366,7 +366,7 @@ def test_apply_post_actions_batch_fallback_caches_uid_store_failure(monkeypatch)
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(post_action="delete"), MessageBus())
channel._apply_post_actions_batch(["123", "124"])
@@ -420,7 +420,7 @@ def test_apply_post_actions_batch_delete_with_post_action_expunge_true_no_uidplu
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(post_action="delete", post_action_expunge=True), MessageBus())
channel._apply_post_actions_batch(["123", "124"])
@@ -569,7 +569,7 @@ def test_fetch_new_messages_skips_self_sent_email_and_marks_seen(monkeypatch) ->
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(from_address="bot@example.com"), MessageBus())
items, skipped_uids = channel._fetch_new_messages()
@@ -638,7 +638,7 @@ def test_fetch_new_messages_skips_self_sent_across_identity_sources(
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(**config_override), MessageBus())
items, _ = channel._fetch_new_messages()
@@ -686,7 +686,7 @@ def test_fetch_new_messages_retries_once_when_imap_connection_goes_stale(monkeyp
fake_instances.append(instance)
return instance
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", _factory)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", _factory)
channel = EmailChannel(_make_config(), MessageBus())
items, _ = channel._fetch_new_messages()
@@ -732,7 +732,7 @@ def test_fetch_new_messages_keeps_messages_collected_before_stale_retry(monkeypa
def logout(self):
return "BYE", [b""]
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: FlakyIMAP())
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: FlakyIMAP())
channel = EmailChannel(_make_config(), MessageBus())
items, _ = channel._fetch_new_messages()
@@ -752,7 +752,7 @@ def test_fetch_new_messages_skips_missing_mailbox(monkeypatch) -> None:
return "BYE", [b""]
monkeypatch.setattr(
"nanobot.channels.email.imaplib.IMAP4_SSL",
"nanobot.channels.email.runtime.imaplib.IMAP4_SSL",
lambda _h, _p: MissingMailboxIMAP(),
)
@@ -827,7 +827,7 @@ async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
fake_instances.append(instance)
return instance
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
channel = EmailChannel(_make_config(), MessageBus())
channel._last_subject_by_chat["alice@example.com"] = "Invoice #42"
@@ -860,7 +860,7 @@ async def test_send_skips_progress_messages_before_smtp(monkeypatch) -> None:
called["smtp"] = True
raise AssertionError("progress messages must not open an SMTP connection")
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
channel = EmailChannel(_make_config(), MessageBus())
@@ -905,7 +905,7 @@ async def test_send_skips_reply_when_auto_reply_disabled(monkeypatch) -> None:
fake_instances.append(instance)
return instance
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
cfg = _make_config()
cfg.auto_reply_enabled = False
@@ -966,7 +966,7 @@ async def test_send_proactive_email_when_auto_reply_disabled(monkeypatch) -> Non
fake_instances.append(instance)
return instance
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
cfg = _make_config()
cfg.auto_reply_enabled = False
@@ -1014,7 +1014,7 @@ async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
called["smtp"] = True
return FakeSMTP(host, port, timeout=timeout)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", _smtp_factory)
cfg = _make_config()
cfg.consent_granted = False
@@ -1059,7 +1059,7 @@ def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(m
return "BYE", [b""]
fake = FakeIMAP()
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
channel = EmailChannel(_make_config(), MessageBus())
items = channel.fetch_messages_between_dates(
@@ -1112,7 +1112,7 @@ def test_spoofed_email_rejected_when_verify_enabled(monkeypatch) -> None:
"""An email without Authentication-Results should be rejected when verify_dkim=True."""
raw = _make_raw_email(subject="Spoofed", body="Malicious payload")
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(verify_dkim=True, verify_spf=True)
channel = EmailChannel(cfg, MessageBus())
@@ -1129,7 +1129,7 @@ def test_email_with_valid_auth_results_accepted(monkeypatch) -> None:
auth_results="mx.example.com; spf=pass smtp.mailfrom=alice@example.com; dkim=pass header.d=example.com",
)
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(verify_dkim=True, verify_spf=True)
channel = EmailChannel(cfg, MessageBus())
@@ -1148,7 +1148,7 @@ def test_email_with_partial_auth_rejected(monkeypatch) -> None:
auth_results="mx.example.com; spf=pass smtp.mailfrom=alice@example.com; dkim=fail",
)
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(verify_dkim=True, verify_spf=True)
channel = EmailChannel(cfg, MessageBus())
@@ -1161,7 +1161,7 @@ def test_backward_compat_verify_disabled(monkeypatch) -> None:
"""When verify_dkim=False and verify_spf=False, emails without auth headers are accepted."""
raw = _make_raw_email(subject="NoAuth", body="No auth headers present")
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(verify_dkim=False, verify_spf=False)
channel = EmailChannel(cfg, MessageBus())
@@ -1174,7 +1174,7 @@ def test_email_content_tagged_with_email_context(monkeypatch) -> None:
"""Email content should be prefixed with [EMAIL-CONTEXT] for LLM isolation."""
raw = _make_raw_email(subject="Tagged", body="Check the tag")
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(verify_dkim=False, verify_spf=False)
channel = EmailChannel(cfg, MessageBus())
@@ -1272,7 +1272,7 @@ def _make_raw_email_with_attachment(
def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monkeypatch) -> None:
raw = _make_raw_email_with_attachment(from_addr="blocked@example.com")
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
called = {"attachments": False}
@@ -1297,11 +1297,11 @@ def test_fetch_new_messages_ignores_unauthorized_sender_before_attachments(monke
def test_extract_attachments_saves_pdf(tmp_path, monkeypatch) -> None:
"""PDF attachment is saved to media dir and path returned in media list."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
raw = _make_raw_email_with_attachment()
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(allowed_attachment_types=["application/pdf"], verify_dkim=False, verify_spf=False)
channel = EmailChannel(cfg, MessageBus())
@@ -1320,7 +1320,7 @@ def test_extract_attachments_disabled_by_default(monkeypatch) -> None:
"""With no allowed_attachment_types (default), no attachments are extracted."""
raw = _make_raw_email_with_attachment()
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(verify_dkim=False, verify_spf=False)
assert cfg.allowed_attachment_types == []
@@ -1334,7 +1334,7 @@ def test_extract_attachments_disabled_by_default(monkeypatch) -> None:
def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
"""Non-allowed MIME types are skipped."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
raw = _make_raw_email_with_attachment(
attachment_name="image.png",
@@ -1342,7 +1342,7 @@ def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
attachment_mime="image/png",
)
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(
allowed_attachment_types=["application/pdf"],
@@ -1358,7 +1358,7 @@ def test_extract_attachments_mime_type_filter(tmp_path, monkeypatch) -> None:
def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypatch) -> None:
"""Empty allowed_attachment_types means no types are accepted."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
raw = _make_raw_email_with_attachment(
attachment_name="image.png",
@@ -1366,7 +1366,7 @@ def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypat
attachment_mime="image/png",
)
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(
allowed_attachment_types=[],
@@ -1382,7 +1382,7 @@ def test_extract_attachments_empty_allowed_types_rejects_all(tmp_path, monkeypat
def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
"""Glob patterns like 'image/*' match attachment MIME types."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
raw = _make_raw_email_with_attachment(
attachment_name="photo.jpg",
@@ -1390,7 +1390,7 @@ def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
attachment_mime="image/jpeg",
)
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(
allowed_attachment_types=["image/*"],
@@ -1406,13 +1406,13 @@ def test_extract_attachments_wildcard_pattern(tmp_path, monkeypatch) -> None:
def test_extract_attachments_size_limit(tmp_path, monkeypatch) -> None:
"""Attachments exceeding max_attachment_size are skipped."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
raw = _make_raw_email_with_attachment(
attachment_content=b"x" * 1000,
)
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(
allowed_attachment_types=["*"],
@@ -1429,7 +1429,7 @@ def test_extract_attachments_size_limit(tmp_path, monkeypatch) -> None:
def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
"""Only max_attachments_per_email are saved."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
# Build email with 3 attachments
msg = EmailMessage()
@@ -1448,7 +1448,7 @@ def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
raw = msg.as_bytes()
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(
allowed_attachment_types=["*"],
@@ -1465,13 +1465,13 @@ def test_extract_attachments_max_count(tmp_path, monkeypatch) -> None:
def test_extract_attachments_sanitizes_filename(tmp_path, monkeypatch) -> None:
"""Path traversal in filenames is neutralized."""
monkeypatch.setattr("nanobot.channels.email.get_media_dir", lambda ch: tmp_path)
monkeypatch.setattr("nanobot.channels.email.runtime.get_media_dir", lambda ch: tmp_path)
raw = _make_raw_email_with_attachment(
attachment_name="../../../etc/passwd",
)
fake = _make_fake_imap(raw)
monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
monkeypatch.setattr("nanobot.channels.email.runtime.imaplib.IMAP4_SSL", lambda _h, _p: fake)
cfg = _make_config(allowed_attachment_types=["*"], verify_dkim=False, verify_spf=False)
channel = EmailChannel(cfg, MessageBus())
@@ -1513,7 +1513,7 @@ async def test_send_with_single_file_attachment(tmp_path, monkeypatch) -> None:
def send_message(self, msg: EmailMessage):
sent_messages.append(msg)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
# Create a real temp file to attach
attachment = tmp_path / "report.pdf"
@@ -1570,7 +1570,7 @@ async def test_send_with_multiple_file_attachments(tmp_path, monkeypatch) -> Non
def send_message(self, msg: EmailMessage):
sent_messages.append(msg)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
file1 = tmp_path / "doc.pdf"
file1.write_bytes(b"%PDF-1.4 doc")
@@ -1627,7 +1627,7 @@ async def test_send_skips_missing_attachment_file(tmp_path, monkeypatch) -> None
def send_message(self, msg: EmailMessage):
sent_messages.append(msg)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
existing = tmp_path / "real.txt"
existing.write_text("I exist")
@@ -1685,7 +1685,7 @@ async def test_send_skips_oversized_attachment_file(tmp_path, monkeypatch) -> No
def send_message(self, msg: EmailMessage):
sent_messages.append(msg)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
attachment = tmp_path / "too-large.bin"
attachment.write_bytes(b"1234")
@@ -1730,7 +1730,7 @@ async def test_send_limits_outbound_attachment_count(tmp_path, monkeypatch) -> N
def send_message(self, msg: EmailMessage):
sent_messages.append(msg)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
file1 = tmp_path / "first.txt"
file1.write_text("first")
@@ -1784,7 +1784,7 @@ async def test_send_with_unknown_mime_type_attachment(tmp_path, monkeypatch) ->
def send_message(self, msg: EmailMessage):
sent_messages.append(msg)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
attachment = tmp_path / "data.unknown_ext_xyz"
attachment.write_bytes(b"some binary data")
@@ -1837,7 +1837,7 @@ async def test_send_with_media_and_reply_subject_and_in_reply_to(tmp_path, monke
def send_message(self, msg: EmailMessage):
sent_messages.append(msg)
monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
monkeypatch.setattr("nanobot.channels.email.runtime.smtplib.SMTP", lambda h, p, timeout=30: FakeSMTP(h, p, timeout=timeout))
attachment = tmp_path / "summary.pdf"
attachment.write_bytes(b"%PDF-1.4 summary")
@@ -0,0 +1,67 @@
from __future__ import annotations
import pytest
from nanobot.channels.email import validation as email_validation
from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
def test_validate_email_presets_are_checked_without_saving(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(email_validation, "probe_tcp", lambda *_args, **_kwargs: None)
result = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
assert result["status"] == "connected"
assert result["can_enable"] is True
assert not hasattr(load_config(config_path).channels, "email")
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.webui_allow_local_service_access = False
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
"nanobot.channels.validation.socket.create_connection",
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
)
result = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "127.0.0.1",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "192.168.1.10",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
warnings = [check["message"] for check in result["checks"] if check["status"] == "warn"]
assert len(warnings) == 2
assert all("private/internal" in message for message in warnings)
+91
View File
@@ -0,0 +1,91 @@
"""Email setup validation owned by the channel package."""
from typing import Any
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import (
check,
int_value,
probe_tcp,
required_checks,
status_from_checks,
string_value,
truthy,
)
def validate(
values: dict[str, Any],
context: ChannelValidationContext,
) -> dict[str, Any]:
checks, missing = required_checks("email", values)
if truthy(values.get("consentGranted")):
checks.append(check("consent", "Mailbox consent", "pass", "Consent is enabled for this mailbox."))
else:
checks.append(
check(
"consent",
"Mailbox consent",
"fail",
"Grant consent before nanobot reads this mailbox.",
)
)
for prefix, default_port in (("imap", 993), ("smtp", 587)):
host = string_value(values.get(f"{prefix}Host"))
port = int_value(values.get(f"{prefix}Port")) or default_port
if not host:
continue
if port <= 0 or port > 65535:
checks.append(
check(
f"{prefix}_port",
f"{prefix.upper()} port",
"fail",
"Port must be between 1 and 65535.",
)
)
continue
checks.append(
check(
f"{prefix}_settings",
f"{prefix.upper()} settings",
"pass",
f"{host}:{port} is set.",
)
)
try:
probe_tcp(
host,
port,
allow_loopback=context.allow_local_service_access,
)
checks.append(
check(
f"{prefix}_reachability",
f"{prefix.upper()} reachability",
"pass",
"The server accepted a TCP connection.",
)
)
except Exception as exc:
checks.append(
check(
f"{prefix}_reachability",
f"{prefix.upper()} reachability",
"warn",
f"Could not verify network reachability now: {exc}",
)
)
identity = {
"account": string_value(
values.get("fromAddress")
or values.get("imapUsername")
or values.get("smtpUsername")
)
}
return status_from_checks("email", checks, missing, identity=identity)
__all__ = ["validate"]
+66
View File
@@ -0,0 +1,66 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import {
type ChannelProviderPresetDefinition,
chatAppGuideUrl,
} from "@/components/settings/channels/catalog";
const EMAIL_PROVIDER_PRESETS: ChannelProviderPresetDefinition[] = [
{
id: "gmail",
values: {
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpPort": "587",
},
},
{
id: "outlook",
values: {
"channels.email.imapHost": "outlook.office365.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.office365.com",
"channels.email.smtpPort": "587",
},
},
{
id: "icloud",
values: {
"channels.email.imapHost": "imap.mail.me.com",
"channels.email.imapPort": "993",
"channels.email.smtpHost": "smtp.mail.me.com",
"channels.email.smtpPort": "587",
},
},
{ id: "custom", values: {} },
];
export default {
presentation: {
displayName: "Email",
initials: "EM",
color: "#64748B",
logoUrl: "https://gmail.com/favicon.ico",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("email"),
presets: EMAIL_PROVIDER_PRESETS,
fields: [
{ key: "channels.email.consentGranted" },
{ key: "channels.email.imapHost" },
{ key: "channels.email.imapUsername" },
{ key: "channels.email.imapPassword" },
{ key: "channels.email.smtpHost" },
{ key: "channels.email.smtpUsername" },
{ key: "channels.email.smtpPassword" },
{ key: "channels.email.imapPort" },
{ key: "channels.email.smtpPort" },
{ key: "channels.email.fromAddress" },
{ key: "channels.email.pollIntervalSeconds" },
{ key: "channels.email.allowFrom" },
{ key: "channels.email.verifyDkim" },
{ key: "channels.email.verifySpf" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,92 @@
{
"description": "Let nanobot receive and answer email messages.",
"requirements": "IMAP inbox, SMTP sender, app password, explicit consent",
"setup": {
"docsLabel": "Open Email setup",
"officialLabel": "Open app password guide",
"tryIt": "Send a test email to the connected mailbox.",
"summary": "Email reads messages over IMAP and replies over SMTP. Use a dedicated mailbox and grant consent before enabling it.",
"steps": [
"Create a dedicated mailbox and, when required, an app password.",
"Choose a provider preset or enter the IMAP and SMTP settings manually.",
"Grant consent, save and enable Email, then send a test message to the mailbox."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Custom"
},
"fields": {
"consentGranted": {
"label": "Consent granted",
"help": "Required safety switch. Leave false until this bot mailbox is intentionally connected.",
"choices": {
"true": "Granted",
"false": "Not granted"
}
},
"imapHost": {
"label": "IMAP host",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP username",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP password",
"placeholder": "App password",
"help": "Use an app password when your mail provider requires one."
},
"smtpHost": {
"label": "SMTP host",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP username",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP password",
"placeholder": "App password",
"help": "Usually the same app password used for IMAP."
},
"imapPort": {
"label": "IMAP port",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP port",
"placeholder": "587"
},
"fromAddress": {
"label": "From address",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Poll interval",
"placeholder": "30"
},
"allowFrom": {
"label": "Allowed senders",
"placeholder": "Email addresses, comma separated",
"help": "Leave empty to require pairing before a sender can use email."
},
"verifyDkim": {
"label": "Verify DKIM",
"choices": {
"true": "On",
"false": "Off"
}
},
"verifySpf": {
"label": "Verify SPF",
"choices": {
"true": "On",
"false": "Off"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permite que nanobot reciba y responda correos.",
"requirements": "Bandeja IMAP, envío SMTP, contraseña de app y consentimiento explícito",
"setup": {
"docsLabel": "Abrir guía de Email",
"officialLabel": "Abrir guía de contraseñas de app",
"tryIt": "Envía un correo de prueba al buzón conectado.",
"summary": "Email lee mensajes por IMAP y responde por SMTP. Usa un buzón dedicado y da tu consentimiento antes de activarlo.",
"steps": [
"Crea un buzón dedicado y, si hace falta, una contraseña de app.",
"Elige un proveedor o introduce manualmente IMAP y SMTP.",
"Da tu consentimiento, guarda y activa Email; después envía un mensaje de prueba."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personalizado"
},
"fields": {
"consentGranted": {
"label": "Consentimiento concedido",
"help": "Control de seguridad obligatorio. Déjalo desactivado hasta decidir conectar este buzón al bot.",
"choices": {
"true": "Concedido",
"false": "No concedido"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Usuario IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Contraseña IMAP",
"placeholder": "Contraseña de app",
"help": "Usa una contraseña de app si el proveedor la exige."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Usuario SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Contraseña SMTP",
"placeholder": "Contraseña de app",
"help": "Normalmente es la misma que para IMAP."
},
"imapPort": {
"label": "Puerto IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Puerto SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Dirección remitente",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalo de consulta",
"placeholder": "30"
},
"allowFrom": {
"label": "Remitentes permitidos",
"placeholder": "Correos separados por comas",
"help": "Déjalo vacío para exigir vinculación previa."
},
"verifyDkim": {
"label": "Verificar DKIM",
"choices": {
"true": "Activado",
"false": "Desactivado"
}
},
"verifySpf": {
"label": "Verificar SPF",
"choices": {
"true": "Activado",
"false": "Desactivado"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permettez à nanobot de recevoir et répondre aux e-mails.",
"requirements": "Boîte IMAP, envoi SMTP, mot de passe dapplication et consentement explicite",
"setup": {
"docsLabel": "Ouvrir le guide Email",
"officialLabel": "Ouvrir le guide des mots de passe dapplication",
"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 lactivation.",
"steps": [
"Créez une boîte dédiée et, si nécessaire, un mot de passe dapplication.",
"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. Nactivez quaprè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 dutilisateur IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Mot de passe IMAP",
"placeholder": "Mot de passe dapplication",
"help": "Utilisez un mot de passe dapplication si le fournisseur lexige."
},
"smtpHost": {
"label": "Hôte SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Nom dutilisateur SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Mot de passe SMTP",
"placeholder": "Mot de passe dapplication",
"help": "Généralement identique à celui dIMAP."
},
"imapPort": {
"label": "Port IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Port SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Adresse denvoi",
"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 lassociation avant utilisation."
},
"verifyDkim": {
"label": "Vérifier DKIM",
"choices": {
"true": "Activé",
"false": "Désactivé"
}
},
"verifySpf": {
"label": "Vérifier SPF",
"choices": {
"true": "Activé",
"false": "Désactivé"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Izinkan nanobot menerima dan membalas email.",
"requirements": "Kotak masuk IMAP, pengirim SMTP, kata sandi aplikasi, dan persetujuan eksplisit",
"setup": {
"docsLabel": "Buka panduan Email",
"officialLabel": "Buka panduan kata sandi aplikasi",
"tryIt": "Kirim email uji ke kotak surat yang terhubung.",
"summary": "Email membaca pesan melalui IMAP dan membalas melalui SMTP. Gunakan kotak surat khusus dan berikan persetujuan sebelum mengaktifkan.",
"steps": [
"Buat kotak surat khusus dan kata sandi aplikasi bila diperlukan.",
"Pilih preset penyedia atau masukkan IMAP dan SMTP secara manual.",
"Berikan persetujuan, simpan dan aktifkan Email, lalu kirim pesan uji."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Kustom"
},
"fields": {
"consentGranted": {
"label": "Persetujuan diberikan",
"help": "Sakelar keamanan wajib. Aktifkan hanya setelah sengaja menghubungkan kotak surat ini ke bot.",
"choices": {
"true": "Diberikan",
"false": "Belum diberikan"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Nama pengguna IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Kata sandi IMAP",
"placeholder": "Kata sandi aplikasi",
"help": "Gunakan kata sandi aplikasi jika diwajibkan penyedia."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Nama pengguna SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Kata sandi SMTP",
"placeholder": "Kata sandi aplikasi",
"help": "Biasanya sama dengan kata sandi aplikasi IMAP."
},
"imapPort": {
"label": "Port IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Port SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Alamat pengirim",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Interval pemeriksaan",
"placeholder": "30"
},
"allowFrom": {
"label": "Pengirim yang diizinkan",
"placeholder": "Alamat email, dipisahkan koma",
"help": "Kosongkan untuk mewajibkan pairing terlebih dahulu."
},
"verifyDkim": {
"label": "Verifikasi DKIM",
"choices": {
"true": "Aktif",
"false": "Nonaktif"
}
},
"verifySpf": {
"label": "Verifikasi SPF",
"choices": {
"true": "Aktif",
"false": "Nonaktif"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "nanobot でメールを受信し、返信します。",
"requirements": "IMAP 受信箱、SMTP 送信、アプリパスワード、明示的な同意",
"setup": {
"docsLabel": "メール設定ガイドを開く",
"officialLabel": "アプリパスワードガイドを開く",
"tryIt": "接続したメールボックスにテストメールを送信します。",
"summary": "メールは IMAP で受信し SMTP で返信します。専用メールボックスを使い、有効化前に同意してください。",
"steps": [
"専用メールボックスを作成し、必要ならアプリパスワードを発行します。",
"プロバイダープリセットを選ぶか、IMAP と SMTP を手動入力します。",
"同意して保存し、メールを有効にしてテストメールを送信します。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "カスタム"
},
"fields": {
"consentGranted": {
"label": "同意済み",
"help": "必須の安全設定です。このボット用メールボックスを接続すると決めるまでオフにしてください。",
"choices": {
"true": "同意済み",
"false": "未同意"
}
},
"imapHost": {
"label": "IMAP ホスト",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP ユーザー名",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP パスワード",
"placeholder": "アプリパスワード",
"help": "プロバイダーが求める場合はアプリパスワードを使います。"
},
"smtpHost": {
"label": "SMTP ホスト",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP ユーザー名",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP パスワード",
"placeholder": "アプリパスワード",
"help": "通常は IMAP と同じアプリパスワードです。"
},
"imapPort": {
"label": "IMAP ポート",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP ポート",
"placeholder": "587"
},
"fromAddress": {
"label": "送信元アドレス",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "確認間隔",
"placeholder": "30"
},
"allowFrom": {
"label": "許可する送信者",
"placeholder": "メールアドレス(カンマ区切り)",
"help": "空欄の場合、送信者は先にペアリングが必要です。"
},
"verifyDkim": {
"label": "DKIM を検証",
"choices": {
"true": "オン",
"false": "オフ"
}
},
"verifySpf": {
"label": "SPF を検証",
"choices": {
"true": "オン",
"false": "オフ"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "nanobot이 이메일을 받고 답장하도록 합니다.",
"requirements": "IMAP 받은편지함, SMTP 발신, 앱 비밀번호 및 명시적 동의",
"setup": {
"docsLabel": "이메일 설정 가이드 열기",
"officialLabel": "앱 비밀번호 가이드 열기",
"tryIt": "연결된 사서함으로 테스트 이메일을 보내세요.",
"summary": "이메일은 IMAP으로 읽고 SMTP로 답장합니다. 전용 사서함을 사용하고 활성화 전에 동의하세요.",
"steps": [
"전용 사서함을 만들고 필요하면 앱 비밀번호를 생성하세요.",
"제공자 프리셋을 선택하거나 IMAP 및 SMTP 설정을 직접 입력하세요.",
"동의하고 저장한 뒤 이메일을 활성화하고 테스트 메시지를 보내세요."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "사용자 지정"
},
"fields": {
"consentGranted": {
"label": "동의함",
"help": "필수 안전 스위치입니다. 이 봇 사서함을 연결하기로 결정하기 전에는 끄세요.",
"choices": {
"true": "동의함",
"false": "동의하지 않음"
}
},
"imapHost": {
"label": "IMAP 호스트",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 사용자 이름",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 비밀번호",
"placeholder": "앱 비밀번호",
"help": "메일 제공자가 요구하면 앱 비밀번호를 사용하세요."
},
"smtpHost": {
"label": "SMTP 호스트",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 사용자 이름",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 비밀번호",
"placeholder": "앱 비밀번호",
"help": "보통 IMAP과 같은 앱 비밀번호를 사용합니다."
},
"imapPort": {
"label": "IMAP 포트",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 포트",
"placeholder": "587"
},
"fromAddress": {
"label": "보내는 주소",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "확인 간격",
"placeholder": "30"
},
"allowFrom": {
"label": "허용된 발신자",
"placeholder": "이메일 주소, 쉼표로 구분",
"help": "비워 두면 발신자가 먼저 페어링해야 합니다."
},
"verifyDkim": {
"label": "DKIM 확인",
"choices": {
"true": "켜짐",
"false": "꺼짐"
}
},
"verifySpf": {
"label": "SPF 확인",
"choices": {
"true": "켜짐",
"false": "꺼짐"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Permita que o nanobot receba e responda e-mails.",
"requirements": "Caixa IMAP, envio SMTP, senha de app e consentimento explícito",
"setup": {
"docsLabel": "Abrir guia de Email",
"officialLabel": "Abrir guia de senhas de app",
"tryIt": "Envie um e-mail de teste para a caixa conectada.",
"summary": "Email lê mensagens por IMAP e responde por SMTP. Use uma caixa dedicada e dê consentimento antes de ativar.",
"steps": [
"Crie uma caixa dedicada e, quando necessário, uma senha de app.",
"Escolha um provedor ou informe IMAP e SMTP manualmente.",
"Dê consentimento, salve e ative Email; depois, envie uma mensagem de teste."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Personalizado"
},
"fields": {
"consentGranted": {
"label": "Consentimento concedido",
"help": "Controle de segurança obrigatório. Deixe desativado até decidir conectar esta caixa ao bot.",
"choices": {
"true": "Concedido",
"false": "Não concedido"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Usuário IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Senha IMAP",
"placeholder": "Senha de app",
"help": "Use uma senha de app quando o provedor exigir."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Usuário SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Senha SMTP",
"placeholder": "Senha de app",
"help": "Normalmente é a mesma senha usada no IMAP."
},
"imapPort": {
"label": "Porta IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Porta SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Endereço remetente",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Intervalo de consulta",
"placeholder": "30"
},
"allowFrom": {
"label": "Remetentes permitidos",
"placeholder": "E-mails separados por vírgulas",
"help": "Deixe vazio para exigir pareamento prévio."
},
"verifyDkim": {
"label": "Verificar DKIM",
"choices": {
"true": "Ativado",
"false": "Desativado"
}
},
"verifySpf": {
"label": "Verificar SPF",
"choices": {
"true": "Ativado",
"false": "Desativado"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "Cho phép nanobot nhận và trả lời email.",
"requirements": "Hộp thư IMAP, gửi SMTP, mật khẩu ứng dụng và sự đồng ý rõ ràng",
"setup": {
"docsLabel": "Mở hướng dẫn Email",
"officialLabel": "Mở hướng dẫn mật khẩu ứng dụng",
"tryIt": "Gửi email thử đến hộp thư đã kết nối.",
"summary": "Email đọc thư qua IMAP và trả lời qua SMTP. Dùng hộp thư riêng và cấp quyền trước khi bật.",
"steps": [
"Tạo hộp thư riêng và mật khẩu ứng dụng nếu cần.",
"Chọn nhà cung cấp hoặc nhập thủ công cài đặt IMAP và SMTP.",
"Cấp quyền, lưu và bật Email, sau đó gửi tin nhắn thử."
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "Tùy chỉnh"
},
"fields": {
"consentGranted": {
"label": "Đã đồng ý",
"help": "Công tắc an toàn bắt buộc. Chỉ bật sau khi chủ động kết nối hộp thư này với bot.",
"choices": {
"true": "Đã đồng ý",
"false": "Chưa đồng ý"
}
},
"imapHost": {
"label": "Host IMAP",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "Tên người dùng IMAP",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "Mật khẩu IMAP",
"placeholder": "Mật khẩu ứng dụng",
"help": "Dùng mật khẩu ứng dụng khi nhà cung cấp yêu cầu."
},
"smtpHost": {
"label": "Host SMTP",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "Tên người dùng SMTP",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "Mật khẩu SMTP",
"placeholder": "Mật khẩu ứng dụng",
"help": "Thường giống mật khẩu ứng dụng dùng cho IMAP."
},
"imapPort": {
"label": "Cổng IMAP",
"placeholder": "993"
},
"smtpPort": {
"label": "Cổng SMTP",
"placeholder": "587"
},
"fromAddress": {
"label": "Địa chỉ gửi",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "Chu kỳ kiểm tra",
"placeholder": "30"
},
"allowFrom": {
"label": "Người gửi được phép",
"placeholder": "Địa chỉ email, phân tách bằng dấu phẩy",
"help": "Để trống để yêu cầu ghép nối trước."
},
"verifyDkim": {
"label": "Xác minh DKIM",
"choices": {
"true": "Bật",
"false": "Tắt"
}
},
"verifySpf": {
"label": "Xác minh SPF",
"choices": {
"true": "Bật",
"false": "Tắt"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "让 nanobot 接收并回复电子邮件。",
"requirements": "IMAP 收件箱、SMTP 发件服务、应用专用密码和明确授权",
"setup": {
"docsLabel": "打开邮件配置指南",
"officialLabel": "打开应用专用密码指南",
"tryIt": "向已连接的邮箱发送一封测试邮件。",
"summary": "邮件渠道通过 IMAP 读取邮件并通过 SMTP 回复。请使用专用邮箱,并在启用前明确授权。",
"steps": [
"创建专用邮箱,并在服务商要求时创建应用专用密码。",
"选择服务商预设,或手动填写 IMAP 和 SMTP 设置。",
"授予授权,保存并启用邮件渠道,然后向邮箱发送一封测试邮件。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "自定义"
},
"fields": {
"consentGranted": {
"label": "已授权",
"help": "必需的安全开关。仅在确定要连接此机器人邮箱后才开启。",
"choices": {
"true": "已授权",
"false": "未授权"
}
},
"imapHost": {
"label": "IMAP 主机",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 用户名",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 密码",
"placeholder": "应用专用密码",
"help": "如果邮件服务商要求,请使用应用专用密码。"
},
"smtpHost": {
"label": "SMTP 主机",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 用户名",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 密码",
"placeholder": "应用专用密码",
"help": "通常与 IMAP 使用同一个应用专用密码。"
},
"imapPort": {
"label": "IMAP 端口",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 端口",
"placeholder": "587"
},
"fromAddress": {
"label": "发件地址",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "轮询间隔",
"placeholder": "30"
},
"allowFrom": {
"label": "允许的发件人",
"placeholder": "邮箱地址,用逗号分隔",
"help": "留空则要求发件人先完成配对。"
},
"verifyDkim": {
"label": "验证 DKIM",
"choices": {
"true": "开启",
"false": "关闭"
}
},
"verifySpf": {
"label": "验证 SPF",
"choices": {
"true": "开启",
"false": "关闭"
}
}
}
}
}
@@ -0,0 +1,92 @@
{
"description": "讓 nanobot 接收並回覆電子郵件。",
"requirements": "IMAP 收件匣、SMTP 寄件服務、應用程式密碼和明確授權",
"setup": {
"docsLabel": "開啟郵件設定指南",
"officialLabel": "開啟應用程式密碼指南",
"tryIt": "向已連接的信箱傳送一封測試郵件。",
"summary": "郵件渠道透過 IMAP 讀取郵件並透過 SMTP 回覆。請使用專用信箱,並在啟用前明確授權。",
"steps": [
"建立專用信箱,並在服務商要求時建立應用程式密碼。",
"選擇服務商預設,或手動填入 IMAP 和 SMTP 設定。",
"授予權限,儲存並啟用郵件渠道,然後向信箱傳送一封測試郵件。"
],
"presets": {
"gmail": "Gmail",
"outlook": "Outlook",
"icloud": "iCloud",
"custom": "自訂"
},
"fields": {
"consentGranted": {
"label": "已授權",
"help": "必要的安全開關。僅在確定要連接此機器人信箱後才開啟。",
"choices": {
"true": "已授權",
"false": "未授權"
}
},
"imapHost": {
"label": "IMAP 主機",
"placeholder": "imap.gmail.com"
},
"imapUsername": {
"label": "IMAP 使用者名稱",
"placeholder": "bot@example.com"
},
"imapPassword": {
"label": "IMAP 密碼",
"placeholder": "應用程式密碼",
"help": "若郵件服務商要求,請使用應用程式密碼。"
},
"smtpHost": {
"label": "SMTP 主機",
"placeholder": "smtp.gmail.com"
},
"smtpUsername": {
"label": "SMTP 使用者名稱",
"placeholder": "bot@example.com"
},
"smtpPassword": {
"label": "SMTP 密碼",
"placeholder": "應用程式密碼",
"help": "通常與 IMAP 使用同一個應用程式密碼。"
},
"imapPort": {
"label": "IMAP 連接埠",
"placeholder": "993"
},
"smtpPort": {
"label": "SMTP 連接埠",
"placeholder": "587"
},
"fromAddress": {
"label": "寄件地址",
"placeholder": "bot@example.com"
},
"pollIntervalSeconds": {
"label": "輪詢間隔",
"placeholder": "30"
},
"allowFrom": {
"label": "允許的寄件者",
"placeholder": "電子郵件地址,以逗號分隔",
"help": "留空則要求寄件者先完成配對。"
},
"verifyDkim": {
"label": "驗證 DKIM",
"choices": {
"true": "開啟",
"false": "關閉"
}
},
"verifySpf": {
"label": "驗證 SPF",
"choices": {
"true": "開啟",
"false": "關閉"
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Feishu/Lark channel package."""
+36
View File
@@ -0,0 +1,36 @@
"""Dependency-free Feishu configuration model shared by management and runtime."""
from typing import Literal
from pydantic import Field
from nanobot.config.schema import Base
class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection."""
instance_id: str = "default"
name: str = "nanobot"
identity_key: str = ""
enabled: bool = False
app_id: str = ""
app_secret: str = ""
encrypt_key: str = ""
verification_token: str = ""
allow_from: list[str] = Field(default_factory=list)
react_emoji: str = "THUMBSUP"
done_emoji: str | None = None
tool_hint_prefix: str = "\U0001f527"
group_policy: Literal["open", "mention"] = "mention"
reply_to_message: bool = False
streaming: bool = True
domain: Literal["feishu", "lark"] = "feishu"
topic_isolation: bool = True
def feishu_default_config() -> dict[str, object]:
return FeishuConfig().model_dump(by_alias=True)
__all__ = ["FeishuConfig", "feishu_default_config"]
+216
View File
@@ -0,0 +1,216 @@
"""Short-lived WebUI channel connection sessions."""
from __future__ import annotations
import asyncio
import json
import secrets
import time
from dataclasses import dataclass
from typing import Any
import httpx
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
from nanobot.channels.feishu import runtime as feishu
from nanobot.channels.feishu.instances import DEFAULT_INSTANCE_ID, validate_instance_id
@dataclass(slots=True)
class FeishuConnectSession:
id: str
instance_id: str
instance_name: str
device_code: str
qr_url: str
domain: str
interval: int
expire_in: int
created_wall: float
deadline: float
last_error: str | None = None
class FeishuConnectStore:
"""In-memory Feishu/Lark QR connection state.
Sessions intentionally live only in the gateway process and expire quickly.
The app secret is never returned to the browser; it is saved directly to
config when Feishu/Lark completes authorization.
"""
def __init__(self) -> None:
self._sessions: dict[str, FeishuConnectSession] = {}
async def handle(self, action: str, query: QueryParams) -> dict[str, Any]:
"""Handle one generic settings connection action."""
if action == "start":
return await asyncio.to_thread(
self.start,
domain=(query_first(query, "domain") or "feishu").strip(),
instance_id=(query_first(query, "instance_id") or "default").strip(),
mode=(query_first(query, "mode") or "replace").strip(),
)
session_id = (query_first(query, "session_id") or "").strip()
if not session_id:
raise ChannelConnectError("missing Feishu connect session")
if action == "poll":
return await asyncio.to_thread(self.poll, session_id)
if action == "cancel":
return self.cancel(session_id)
raise ChannelConnectError(f"unsupported Feishu connect action: {action}", status=404)
def start(
self,
*,
domain: str = "feishu",
instance_id: str = DEFAULT_INSTANCE_ID,
mode: str = "replace",
) -> dict[str, Any]:
domain = _normalize_domain(domain)
instance_id = _resolve_instance_id(instance_id, mode)
self._cleanup()
try:
feishu._init_registration(domain)
begin = feishu._begin_registration(domain)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
raise ChannelConnectError(
f"Unable to start Feishu/Lark connection: {exc}",
status=502,
) from exc
session_id = secrets.token_urlsafe(18)
now_wall = time.time()
now = time.monotonic()
expire_in = int(begin["expire_in"])
interval = max(2, int(begin["interval"]))
session = FeishuConnectSession(
id=session_id,
instance_id=instance_id,
instance_name=_default_instance_name(instance_id),
device_code=str(begin["device_code"]),
qr_url=str(begin["qr_url"]),
domain=domain,
interval=interval,
expire_in=expire_in,
created_wall=now_wall,
deadline=now + expire_in,
)
self._sessions[session_id] = session
return _start_payload(session)
def poll(self, session_id: str) -> dict[str, Any]:
self._cleanup()
session = self._sessions.get(session_id)
if session is None:
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
if time.monotonic() >= session.deadline:
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
try:
result = feishu.poll_registration_once(
device_code=session.device_code,
domain=session.domain,
)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
session.last_error = str(exc)
return _pending_payload(session)
session.domain = str(result.get("domain") or session.domain)
status = result.get("status")
if status == "succeeded":
session.instance_id = feishu.save_registration_result(
result,
instance_id=session.instance_id,
name=session.instance_name,
)
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "succeeded",
"message": "Feishu is connected.",
"domain": session.domain,
"app_id": result.get("app_id"),
}
if status == "failed":
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "failed",
"message": "Authorization was cancelled or expired.",
"domain": session.domain,
}
return _pending_payload(session)
def cancel(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
"status": "cancelled",
"message": "Feishu connection cancelled.",
}
def _cleanup(self) -> None:
now = time.monotonic()
expired = [session_id for session_id, session in self._sessions.items() if now >= session.deadline]
for session_id in expired:
self._sessions.pop(session_id, None)
def _normalize_domain(domain: str) -> str:
normalized = domain.strip().lower()
return normalized if normalized in {"feishu", "lark"} else "feishu"
def _resolve_instance_id(instance_id: str, mode: str) -> str:
if mode == "create":
return f"assistant-{secrets.token_hex(3)}"
try:
return validate_instance_id(instance_id or DEFAULT_INSTANCE_ID)
except ValueError as exc:
raise ChannelConnectError(str(exc), status=400) from exc
def _default_instance_name(instance_id: str) -> str:
return "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"
def _start_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"qr_url": session.qr_url,
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Scan with Feishu or Lark to connect.",
}
def _pending_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Waiting for authorization.",
}

Some files were not shown because too many files have changed in this diff Show More