feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+17
View File
@@ -255,6 +255,19 @@ without hand-editing JSON. Enabling may install the support package first.
Disabling is for channels such as Telegram, Matrix, or Slack; it keeps your
saved settings and turns the channel off.
The `plugins` command name is retained for compatibility, but these entries are
nanobot runtime support packages, not the user-invokable tools shown in WebUI
Apps. They cannot be attached to a chat turn with `@`.
| Feature name | What it enables |
|---|---|
| `api` | Dependencies required by the OpenAI-compatible `nanobot serve` process |
| `azure` | Azure identity support for Azure-hosted models |
| `bedrock` | AWS Bedrock model provider support |
| `langfuse` | Langfuse tracing support for OpenAI-compatible providers |
| `olostep` | Olostep web search provider support |
| A channel name such as `telegram` or `slack` | The connector package and saved channel enablement |
| Command | Description |
|---|---|
| `nanobot plugins list` | Show available channels and optional capabilities |
@@ -265,6 +278,10 @@ saved settings and turns the channel off.
| `nanobot plugins enable <name> --config <path>` | Update a specific config file |
| `nanobot plugins disable <channel> --config <path>` | Turn off a channel in a specific config file |
Document and PDF reading are included in the standard installation. The old
`nanobot plugins enable documents` and `nanobot plugins enable pdf` commands
remain accepted as no-op compatibility aliases.
## Provider OAuth
| Command | Description |
+2 -2
View File
@@ -213,7 +213,7 @@ nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK
Install the optional package in the same Python environment that runs nanobot:
```bash
python -m pip install langfuse
nanobot plugins enable langfuse
```
Set Langfuse credentials before starting `nanobot agent`, `nanobot gateway`, or `nanobot serve`:
@@ -1541,7 +1541,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
| `sendProgress` | `true` | Stream agent's text progress to the channel |
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix / Mattermost keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Install parser dependencies with `nanobot plugins enable documents`. If you used document parsing before those parsers became optional, run that command after upgrading. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. PDF, DOCX, XLSX, and PPTX readers are included in the standard installation. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
+20 -19
View File
@@ -115,21 +115,22 @@ for provider setup and output behavior.
## Apps
Open Apps from the sidebar or settings navigation to manage integrations that
nanobot can call from a chat. Nanobot features can enable built-in channels and
optional capabilities such as `bedrock` or `documents`. CLI Apps install local
adapters that nanobot runs on your machine; they do not modify the native apps
themselves. MCP presets add predefined MCP server configurations.
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
turn. The default **Ready** view shows only tools that can be used immediately:
Enabling a Nanobot feature may install Python packages into the environment
running nanobot. By default, the WebUI can install missing packages only when
you open it on the same machine as nanobot. If you open the WebUI from another
device, a domain name, a tunnel, or a reverse proxy, package install is blocked
unless you explicitly allow it with `tools.webuiAllowRemotePackageInstall`.
- **Apps** are local command-line adapters that nanobot runs on your machine.
Installing an adapter does not modify the native desktop or web app it
connects to.
- **Integrations** are MCP servers. Presets provide known configurations, and
the custom integration panel accepts stdio, HTTP, and SSE servers.
Optional feature installs use your existing pip download settings. If PyPI is
slow or unavailable from your network, configure pip or set `PIP_INDEX_URL`
before starting nanobot.
Apps intentionally does not list nanobot runtime support packages such as
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
are not tools that can be attached to a turn with `@`. Manage them from
**System**, **Models**, or **Web**. PDF and common Office document readers are
included in nanobot and activate automatically when a file is attached. The
equivalent CLI for optional integrations remains `nanobot plugins`. See
[`cli-reference.md`](./cli-reference.md#optional-features).
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
@@ -137,8 +138,8 @@ extraction tools without requiring an API key. This does not replace nanobot's
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
turn needs Firecrawl's richer web data tools.
After an App or MCP preset is available, mention it from the composer with `@`
to attach that capability to the next message.
After an App or integration is available, mention it from the composer with
`@` to attach that tool to the next message.
## Skills
@@ -226,10 +227,10 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
`http://<your-ip>:8765` from the other device and enter the secret in the login
form.
Remote WebUI clients can view Apps and toggle already-installed features with a
valid token, but they cannot install missing Python packages by default. To allow
trusted remote admins to install optional feature dependencies from the WebUI,
opt in explicitly:
Remote WebUI clients with a valid token can view and use Apps. Actions that
install missing nanobot support packages, such as adding a channel dependency,
are blocked by default. To let trusted remote administrators change the Python
environment through the WebUI, opt in explicitly:
```json
{
+32 -30
View File
@@ -4,7 +4,7 @@ Triggered automatically by `python -m build` (and any other hatch-driven build)
so published wheels and sdists ship a fresh webui without requiring developers
to remember `cd webui && bun run build` beforehand.
Behaviour:
Behavior:
- Skips for editable installs (`pip install -e .`). Editable mode is for Python
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
@@ -12,7 +12,7 @@ Behaviour:
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that
already contains a prebuilt `nanobot/web/dist/`).
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
- Skips when `nanobot/web/dist/index.html` already exists, unless
- Reuses `nanobot/web/dist/` only when it is already fresh, unless
`NANOBOT_FORCE_WEBUI_BUILD=1` is set.
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
performs `install` followed by `run build`.
@@ -21,12 +21,22 @@ Behaviour:
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
from types import ModuleType
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
_PROJECT_ROOT = Path(__file__).resolve().parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
def _load_webui_build_module() -> ModuleType:
from nanobot.webui import build as webui_build
return webui_build
class WebUIBuildHook(BuildHookInterface):
PLUGIN_NAME = "webui-build"
@@ -58,24 +68,32 @@ class WebUIBuildHook(BuildHookInterface):
)
return
webui_build = _load_webui_build_module()
status = webui_build.inspect_webui_bundle(source_dir=webui_dir, dist_dir=dist_dir)
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
if index_html.is_file() and not force:
if not status.needs_build and not force:
self.app.display_info(
f"[webui-build] reusing existing build at {dist_dir} "
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
"(already fresh; set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
)
return
runner = self._pick_runner()
if runner is None:
raise RuntimeError(
"[webui-build] neither `bun` nor `npm` is available on PATH; "
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
if status.needs_build and not force:
self.app.display_info(
f"[webui-build] {webui_build.describe_webui_bundle_status(status)}"
)
self.app.display_info(f"[webui-build] using {runner} to build webui")
self._run([runner, "install"], cwd=webui_dir)
self._run([runner, "run", "build"], cwd=webui_dir)
try:
webui_build.build_webui_bundle(
source_dir=webui_dir,
dist_dir=dist_dir,
output=self.app.display_info,
)
except webui_build.WebUIBuildError as exc:
raise RuntimeError(
"[webui-build] "
f"{exc}. Install `bun` or `npm`, or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
) from exc
if not index_html.is_file():
raise RuntimeError(
@@ -83,19 +101,3 @@ class WebUIBuildHook(BuildHookInterface):
"check webui/vite.config.ts outDir."
)
self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
@staticmethod
def _pick_runner() -> str | None:
for candidate in ("bun", "npm"):
if shutil.which(candidate):
return candidate
return None
def _run(self, cmd: list[str], *, cwd: Path) -> None:
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
try:
subprocess.run(cmd, cwd=cwd, check=True)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
) from exc
+20 -47
View File
@@ -211,17 +211,6 @@ def _builtin_skill_read_path(path: str) -> Path | None:
return candidate if candidate.is_file() else None
def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
"""Parse a page range like '2-5' into 0-based (start, end) inclusive."""
parts = pages.strip().split("-")
if len(parts) == 1:
p = int(parts[0])
return max(0, p - 1), min(p - 1, total - 1)
start = int(parts[0])
end = int(parts[1])
return max(0, start - 1), min(end - 1, total - 1)
@tool_parameters(
tool_parameters_schema(
path=StringSchema("The file path to read"),
@@ -405,49 +394,33 @@ class ReadFileTool(_FsTool):
return ToolResult.error(f"Error reading file: {e}")
def _read_pdf(self, fp: Path, pages: str | None) -> str:
try:
import fitz # pymupdf
except ImportError:
return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
from nanobot.utils.document import PdfPageRangeError, PdfSafetyError, extract_pdf_pages
try:
doc = fitz.open(str(fp))
extraction = extract_pdf_pages(
fp,
pages=pages,
max_pages=self._MAX_PDF_PAGES,
max_chars=self._MAX_CHARS,
)
except PdfPageRangeError:
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
except PdfSafetyError as e:
return ToolResult.error(f"Error reading PDF: {e}")
except Exception as e:
return ToolResult.error(f"Error reading PDF: {e}")
total_pages = len(doc)
if pages:
try:
start, end = _parse_page_range(pages, total_pages)
except (ValueError, IndexError):
doc.close()
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
if start > end or start >= total_pages:
doc.close()
return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
else:
start = 0
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
if end - start + 1 > self._MAX_PDF_PAGES:
end = start + self._MAX_PDF_PAGES - 1
parts: list[str] = []
for i in range(start, end + 1):
page = doc[i]
text = page.get_text().strip()
if text:
parts.append(f"--- Page {i + 1} ---\n{text}")
doc.close()
if not parts:
if not extraction.text:
return f"(PDF has no extractable text: {fp})"
result = "\n\n".join(parts)
if end < total_pages - 1:
result += f"\n\n(Showing pages {start + 1}-{end + 1} of {total_pages}. Use pages='{end + 2}-{min(end + 1 + self._MAX_PDF_PAGES, total_pages)}' to continue.)"
if len(result) > self._MAX_CHARS:
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
result = extraction.text
if extraction.end_page < extraction.total_pages - 1:
next_start = extraction.end_page + 2
next_end = min(extraction.end_page + 1 + self._MAX_PDF_PAGES, extraction.total_pages)
result += (
f"\n\n(Showing pages {extraction.start_page + 1}-{extraction.end_page + 1} "
f"of {extraction.total_pages}. Use pages='{next_start}-{next_end}' to continue.)"
)
return result
def _read_office_doc(self, fp: Path) -> str:
+60
View File
@@ -0,0 +1,60 @@
"""Background process control for the WebUI-managed OpenAI-compatible API."""
from __future__ import annotations
import hashlib
import sys
from dataclasses import dataclass
from pathlib import Path
from nanobot.process_runtime import (
ManagedProcessRuntime,
ProcessRuntimePaths,
ProcessStartOptions,
)
@dataclass(frozen=True)
class ApiStartOptions(ProcessStartOptions):
"""Options needed to start a managed ``nanobot serve`` process."""
host: str = "127.0.0.1"
def api_runtime_paths(config_path: Path) -> ProcessRuntimePaths:
"""Return isolated state and log paths for one API process."""
resolved = config_path.expanduser().resolve(strict=False)
suffix = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[:16]
run_dir = resolved.parent / "run"
logs_dir = resolved.parent / "logs"
return ProcessRuntimePaths(
run_dir=run_dir,
logs_dir=logs_dir,
state_path=run_dir / f"api.{suffix}.json",
log_path=logs_dir / f"api.{suffix}.log",
)
class ApiRuntime(ManagedProcessRuntime):
"""Manage a WebUI-controlled OpenAI-compatible API process."""
service_name = "api"
def _build_child_command(self, options: ApiStartOptions) -> list[str]:
command = [
self.python_executable or sys.executable,
"-m",
"nanobot",
"serve",
"--host",
options.host,
"--port",
str(options.port),
]
if options.verbose:
command.append("--verbose")
if options.workspace:
command.extend(["--workspace", options.workspace])
if options.config_path:
command.extend(["--config", options.config_path])
return command
+184
View File
@@ -0,0 +1,184 @@
"""Helpers for channel instance configuration.
The first consumer is Feishu/Lark. Keep the helpers small and data-oriented so
ChannelManager can support Feishu assistant instances without turning every
channel into a multi-instance abstraction.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from loguru import logger
from nanobot.config.loader import merge_missing_defaults
DEFAULT_INSTANCE_ID = "default"
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
@dataclass(frozen=True)
class ChannelInstanceSpec:
"""Runtime description for one channel instance."""
base_name: str
instance_id: str
runtime_name: str
config: dict[str, Any]
def validate_instance_id(value: str) -> str:
"""Return a normalized instance id or raise ValueError."""
instance_id = value.strip()
if not instance_id or not _INSTANCE_ID_RE.fullmatch(instance_id):
raise ValueError("instance id must match [A-Za-z0-9_-]+")
return instance_id
def runtime_channel_name(base_name: str, instance_id: str) -> str:
"""Return the channel key used for routing messages at runtime."""
return base_name if instance_id == DEFAULT_INSTANCE_ID else f"{base_name}.{instance_id}"
def _base_feishu_instance_config(defaults: dict[str, Any]) -> dict[str, Any]:
config = dict(defaults)
config["instanceId"] = DEFAULT_INSTANCE_ID
config["name"] = "nanobot"
return config
def _normalize_feishu_instance(
raw: dict[str, Any],
defaults: dict[str, Any],
*,
inherited: dict[str, Any] | None = None,
fallback_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
config = merge_missing_defaults(inherited or {}, defaults)
config = merge_missing_defaults(raw, config)
raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id
instance_id = validate_instance_id(str(raw_id))
config["id"] = instance_id
config["instanceId"] = instance_id
config.setdefault("name", "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}")
return config
def feishu_instance_specs(
section: Any,
defaults: dict[str, Any],
*,
enabled_only: bool = False,
) -> list[ChannelInstanceSpec]:
"""Expand legacy or canonical Feishu config into runtime instance specs."""
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
section = {}
instances = section.get("instances")
raw_specs: list[dict[str, Any]]
inherited: dict[str, Any] | None = None
if isinstance(instances, list):
inherited = {key: value for key, value in section.items() if key != "instances"}
raw_specs = [item for item in instances if isinstance(item, dict)]
else:
raw_specs = [section] if section else [_base_feishu_instance_config(defaults)]
specs: list[ChannelInstanceSpec] = []
for index, raw in enumerate(raw_specs):
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try:
config = _normalize_feishu_instance(
raw,
defaults,
inherited=inherited,
fallback_id=fallback_id,
)
except ValueError as exc:
logger.warning("Skipping invalid Feishu instance config: {}", exc)
continue
enabled = bool(config.get("enabled", defaults.get("enabled", False)))
if enabled_only and not enabled:
continue
instance_id = str(config["instanceId"])
specs.append(
ChannelInstanceSpec(
base_name="feishu",
instance_id=instance_id,
runtime_name=runtime_channel_name("feishu", instance_id),
config=config,
)
)
return specs
def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str, Any]:
"""Return Feishu config in the canonical ``instances`` shape."""
specs = feishu_instance_specs(section, defaults)
return {"instances": [dict(spec.config) for spec in specs]}
def upsert_feishu_instance(
section: Any,
defaults: dict[str, Any],
instance_id: str,
values: dict[str, Any],
) -> dict[str, Any]:
"""Return canonical Feishu section with one instance created or updated."""
instance_id = validate_instance_id(instance_id)
canonical = canonical_feishu_section(section, defaults)
instances = canonical.setdefault("instances", [])
for instance in instances:
if instance.get("id") == instance_id or instance.get("instanceId") == instance_id:
instance.update(values)
instance["id"] = instance_id
instance["instanceId"] = instance_id
instance.setdefault("name", "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}")
return canonical
config = _normalize_feishu_instance(
{**values, "id": instance_id},
defaults,
fallback_id=instance_id,
)
instances.append(config)
return canonical
def update_feishu_instance_preserving_shape(
section: Any,
defaults: dict[str, Any],
instance_id: str,
values: dict[str, Any],
) -> dict[str, Any]:
"""Update background metadata without migrating a legacy flat section."""
instance_id = validate_instance_id(instance_id)
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if (
instance_id == DEFAULT_INSTANCE_ID
and isinstance(section, dict)
and not isinstance(section.get("instances"), list)
):
return {**section, **values}
return upsert_feishu_instance(section, defaults, instance_id, values)
def set_feishu_instance_enabled(
section: Any,
defaults: dict[str, Any],
instance_id: str,
enabled: bool,
) -> dict[str, Any]:
"""Return canonical Feishu section with one instance's enabled flag updated."""
return upsert_feishu_instance(section, defaults, instance_id, {"enabled": enabled})
+126
View File
@@ -0,0 +1,126 @@
"""Shared Feishu/Lark WebSocket runtime.
The official lark_oapi websocket client stores an asyncio loop in a module-level
variable. Running one blocking ``Client.start()`` per assistant would make
multiple Feishu instances fragile, so this module centralizes the loop patch and
starts each client through the SDK's async primitives on one dedicated loop.
"""
from __future__ import annotations
import asyncio
import threading
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from loguru import logger
@dataclass
class _ClientRuntime:
client: Any
stop_event: asyncio.Event
task: asyncio.Task
class FeishuWsRunner:
"""Run multiple lark_oapi websocket clients on one dedicated event loop."""
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._ready = threading.Event()
self._lock = threading.Lock()
self._clients: dict[str, _ClientRuntime] = {}
async def start_client(self, key: str, client: Any) -> None:
"""Start or replace one client runtime."""
loop = self._ensure_loop()
await asyncio.wrap_future(
asyncio.run_coroutine_threadsafe(self._start_client(key, client), loop)
)
async def stop_client(self, key: str) -> None:
"""Stop one client runtime if it is active."""
loop = self._loop
if loop is None or loop.is_closed():
return
await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self._stop_client(key), loop))
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
with self._lock:
if self._loop is not None and not self._loop.is_closed():
return self._loop
self._ready.clear()
self._thread = threading.Thread(target=self._run_loop, name="feishu-ws", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=10) or self._loop is None:
raise RuntimeError("Feishu WebSocket runner did not start")
return self._loop
def _run_loop(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
import lark_oapi.ws.client as lark_ws_client
lark_ws_client.loop = loop
self._loop = loop
self._ready.set()
loop.run_forever()
finally:
with suppress(Exception):
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
async def _start_client(self, key: str, client: Any) -> None:
await self._stop_client(key)
stop_event = asyncio.Event()
task = asyncio.create_task(self._client_main(key, client, stop_event))
self._clients[key] = _ClientRuntime(client=client, stop_event=stop_event, task=task)
async def _stop_client(self, key: str) -> None:
runtime = self._clients.pop(key, None)
if runtime is None:
return
runtime.stop_event.set()
with suppress(Exception):
await runtime.client._disconnect()
runtime.task.cancel()
with suppress(asyncio.CancelledError):
await runtime.task
async def _client_main(self, key: str, client: Any, stop_event: asyncio.Event) -> None:
ping_task: asyncio.Task | None = None
while not stop_event.is_set():
try:
await client._connect()
ping_task = asyncio.create_task(client._ping_loop())
await stop_event.wait()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Feishu WebSocket client '{}' failed: {}", key, exc)
with suppress(Exception):
await client._disconnect()
if not stop_event.is_set():
await asyncio.sleep(5)
finally:
if ping_task is not None:
ping_task.cancel()
with suppress(asyncio.CancelledError):
await ping_task
with suppress(Exception):
await client._disconnect()
_RUNNER: FeishuWsRunner | None = None
def get_feishu_ws_runner() -> FeishuWsRunner:
"""Return the process-wide Feishu WebSocket runner."""
global _RUNNER
if _RUNNER is None:
_RUNNER = FeishuWsRunner()
return _RUNNER
+343
View File
@@ -0,0 +1,343 @@
"""Shared channel setup contract for configuration, display, and validation."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
RouteFieldType = str | tuple[str, set[str]]
@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",
*,
choices: set[str] | None = None,
writable: bool = True,
snapshot: bool = True,
) -> ChannelFieldSpec:
return ChannelFieldSpec(
kind=kind,
choices=frozenset(choices or ()),
writable=writable,
snapshot=snapshot,
)
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)
+414 -69
View File
@@ -13,6 +13,7 @@ import uuid
from collections import OrderedDict
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field
@@ -24,10 +25,19 @@ from rich.text import Text
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels._feishu_instances import (
DEFAULT_INSTANCE_ID,
feishu_instance_specs,
runtime_channel_name,
update_feishu_instance_preserving_shape,
upsert_feishu_instance,
)
from nanobot.channels._feishu_ws import get_feishu_ws_runner
from nanobot.channels.base import BaseChannel
from nanobot.command.router import normalize_command_text
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.pairing import clear_channel
from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging
@@ -38,6 +48,10 @@ FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
_LOGIN_CONSOLE = Console()
def _identity_timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
def _load_lark_runtime() -> tuple[Any, str, str]:
"""Import the heavy Feishu SDK lazily.
@@ -68,6 +82,58 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
return lark, FEISHU_DOMAIN, LARK_DOMAIN
def fetch_feishu_app_identity(
app_id: str,
app_secret: str,
domain: str = "feishu",
) -> dict[str, str]:
"""Fetch the user-facing Feishu/Lark app identity for display.
This is best-effort metadata for WebUI presentation. Callers should treat
an empty result as a normal fallback path.
"""
if not FEISHU_AVAILABLE or not app_id or not app_secret:
return {}
try:
lark, feishu_domain, lark_domain = _load_lark_runtime()
from lark_oapi.api.application.v6.model.get_application_request import (
GetApplicationRequest,
)
sdk_domain = lark_domain if domain == "lark" else feishu_domain
client = (
lark.Client.builder()
.app_id(app_id)
.app_secret(app_secret)
.domain(sdk_domain)
.timeout(5)
.build()
)
request = GetApplicationRequest.builder().app_id(app_id).lang("zh_cn").build()
response = client.application.v6.application.get(request)
if hasattr(response, "success") and not response.success():
return {}
app = getattr(getattr(response, "data", None), "app", None)
if app is None:
return {}
identity: dict[str, str] = {}
display_name = str(getattr(app, "app_name", "") or "").strip()
avatar_url = str(getattr(app, "avatar_url", "") or "").strip()
if display_name:
identity["displayName"] = display_name
if avatar_url:
identity["avatarUrl"] = avatar_url
if identity:
identity["identityFetchedAt"] = _identity_timestamp()
return identity
except Exception:
return {}
# Message type display mapping
MSG_TYPE_MAP = {
"image": "[image]",
@@ -343,6 +409,9 @@ def _extract_post_text(content_json: dict) -> str:
class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection."""
instance_id: str = DEFAULT_INSTANCE_ID
name: str = "nanobot"
identity_key: str = ""
enabled: bool = False
app_id: str = ""
app_secret: str = ""
@@ -448,39 +517,24 @@ def _poll_registration(
"""
deadline = time.monotonic() + expire_in
current_domain = domain
poll_count = 0
while time.monotonic() < deadline:
base_url = _accounts_base_url(current_domain)
try:
res = _post_registration(base_url, {
"action": "poll",
"device_code": device_code,
"tp": "ob_app",
})
res = poll_registration_once(device_code=device_code, domain=current_domain)
except Exception:
time.sleep(interval)
continue
poll_count += 1
current_domain = res.get("domain", current_domain)
# Domain auto-detection: if the user's tenant is on Lark, switch automatically
user_info = res.get("user_info") or {}
tenant_brand = user_info.get("tenant_brand")
if tenant_brand == "lark":
current_domain = "lark"
# Success
if res.get("client_id") and res.get("client_secret"):
if res.get("status") == "succeeded":
return {
"app_id": res["client_id"],
"app_secret": res["client_secret"],
"domain": current_domain,
"app_id": res["app_id"],
"app_secret": res["app_secret"],
"domain": res.get("domain", current_domain),
}
# Terminal errors
error = res.get("error", "")
if error in ("access_denied", "expired_token"):
if res.get("status") == "failed":
_LOGIN_CONSOLE.print("[yellow]Authorization was cancelled or expired.[/yellow]")
return None
@@ -491,6 +545,230 @@ def _poll_registration(
return None
def poll_registration_once(
*,
device_code: str,
domain: str = "feishu",
) -> dict:
"""Poll the Feishu/Lark device-code flow once.
This non-blocking shape is used by WebUI. The CLI keeps using
``_poll_registration`` to wait in the terminal.
"""
current_domain = domain
base_url = _accounts_base_url(current_domain)
res = _post_registration(base_url, {
"action": "poll",
"device_code": device_code,
"tp": "ob_app",
})
user_info = res.get("user_info") or {}
tenant_brand = user_info.get("tenant_brand")
if tenant_brand == "lark":
current_domain = "lark"
if res.get("client_id") and res.get("client_secret"):
return {
"status": "succeeded",
"app_id": res["client_id"],
"app_secret": res["client_secret"],
"domain": current_domain,
}
error = res.get("error", "")
if error in ("access_denied", "expired_token"):
return {
"status": "failed",
"error": error,
"domain": current_domain,
}
return {
"status": "pending",
"domain": current_domain,
}
def _feishu_app_identity_key(app_id: str, domain: str) -> str:
normalized_app_id = app_id.strip()
normalized_domain = "lark" if domain.strip().lower() == "lark" else "feishu"
return f"{normalized_domain}:{normalized_app_id}" if normalized_app_id else ""
def _saved_feishu_instance_identity_key(
feishu_cfg: Any,
defaults: dict[str, Any],
instance_id: str,
) -> str:
for spec in feishu_instance_specs(feishu_cfg, defaults):
if spec.instance_id == instance_id:
return _feishu_app_identity_key(
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
str(spec.config.get("domain") or "feishu"),
)
return ""
def sync_saved_feishu_identity_boundary(
*,
instance_id: str,
app_id: str,
domain: str,
) -> bool:
"""Persist the Feishu app identity marker and clear access if it changed.
WebUI connect normally handles this at save time. This startup check catches
manual config edits so approved users do not accidentally carry over to a
different Feishu/Lark app in the same local instance slot.
"""
current_identity_key = _feishu_app_identity_key(app_id, domain)
if not current_identity_key:
return False
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config()
previous_identity_key = ""
for spec in feishu_instance_specs(feishu_cfg, defaults):
if spec.instance_id == instance_id:
previous_identity_key = str(
spec.config.get("identityKey") or spec.config.get("identity_key") or ""
)
break
access_cleared = bool(previous_identity_key and previous_identity_key != current_identity_key)
values: dict[str, Any] = {"identityKey": current_identity_key}
if access_cleared:
values["allowFrom"] = []
values["allow_from"] = []
clear_channel(runtime_channel_name("feishu", instance_id))
if not previous_identity_key or access_cleared:
feishu_cfg = update_feishu_instance_preserving_shape(
feishu_cfg,
defaults,
instance_id,
values,
)
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
return access_cleared
def save_registration_result(
result: dict,
*,
instance_id: str = DEFAULT_INSTANCE_ID,
name: str | None = None,
) -> None:
"""Persist a successful Feishu/Lark registration result to config.json."""
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config()
app_id = str(result["app_id"]).strip()
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
domain = "lark" if domain == "lark" else "feishu"
previous_identity_key = _saved_feishu_instance_identity_key(feishu_cfg, defaults, instance_id)
next_identity_key = _feishu_app_identity_key(app_id, domain)
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
identity: dict[str, str] = {}
with suppress(Exception):
identity = fetch_feishu_app_identity(
app_id,
str(result["app_secret"]),
domain,
)
values = {
"name": name or ("nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"),
"appId": app_id,
"appSecret": result["app_secret"],
"domain": domain,
"identityKey": next_identity_key,
"enabled": True,
**identity,
}
if identity_changed:
values["allowFrom"] = []
values["allow_from"] = []
clear_channel(runtime_channel_name("feishu", instance_id))
feishu_cfg = upsert_feishu_instance(
feishu_cfg,
defaults,
instance_id,
values,
)
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
"""Backfill missing Feishu assistant display identity in saved config.
Existing users may already have working App ID/Secret credentials from
older builds. Fetch identity only when an instance has credentials but no
identity metadata at all, then persist the attempt so Settings does not hit
Feishu on every render.
"""
if not FEISHU_AVAILABLE:
return False
from nanobot.config.loader import load_config, save_config
full_config = config or load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None)
defaults = FeishuChannel.default_config()
specs = feishu_instance_specs(feishu_cfg, defaults)
updated = False
for spec in specs:
instance = spec.config
if (
instance.get("displayName")
or instance.get("avatarUrl")
or instance.get("identityFetchedAt")
):
continue
app_id = str(instance.get("appId") or instance.get("app_id") or "").strip()
app_secret = str(instance.get("appSecret") or instance.get("app_secret") or "").strip()
if not app_id or not app_secret:
continue
identity = fetch_feishu_app_identity(
app_id,
app_secret,
str(instance.get("domain") or "feishu"),
)
if not identity:
identity = {"identityFetchedAt": _identity_timestamp()}
feishu_cfg = update_feishu_instance_preserving_shape(
feishu_cfg,
defaults,
spec.instance_id,
identity,
)
updated = True
if not updated:
return False
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
return True
def qr_register(
*,
initial_domain: str = "feishu",
@@ -600,7 +878,7 @@ class FeishuChannel(BaseChannel):
self.config: FeishuConfig = config
self._client: Any = None
self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None
self._ws_runner = get_feishu_ws_runner()
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
self._loop: asyncio.AbstractEventLoop | None = None
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
@@ -650,18 +928,11 @@ class FeishuChannel(BaseChannel):
self.config.app_secret = result["app_secret"]
self.config.domain = result.get("domain", "feishu")
# Write credentials back to config.json
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if isinstance(feishu_cfg, dict):
feishu_cfg["appId"] = result["app_id"]
feishu_cfg["appSecret"] = result["app_secret"]
feishu_cfg["domain"] = result.get("domain", "feishu")
feishu_cfg["enabled"] = True
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
save_registration_result(
result,
instance_id=self.config.instance_id,
name=self.config.name,
)
_LOGIN_CONSOLE.print("\n[green]Feishu/Lark login complete.[/green]")
_LOGIN_CONSOLE.print(f"App ID: {escape(result['app_id'])}")
@@ -687,6 +958,18 @@ class FeishuChannel(BaseChannel):
)
return
if sync_saved_feishu_identity_boundary(
instance_id=self.config.instance_id,
app_id=self.config.app_id,
domain=self.config.domain,
):
self.config.identity_key = _feishu_app_identity_key(self.config.app_id, self.config.domain)
self.config.allow_from = []
self.logger.info(
"Feishu app identity changed for {}; cleared paired users for this assistant",
self.name,
)
lark, feishu_domain, lark_domain = await asyncio.to_thread(_load_lark_runtime)
redirect_lib_logging("Lark")
@@ -745,38 +1028,7 @@ class FeishuChannel(BaseChannel):
log_level=lark.LogLevel.INFO,
)
# Start WebSocket client in a separate thread with reconnect loop.
# A dedicated event loop is created for this thread so that lark_oapi's
# module-level `loop = asyncio.get_event_loop()` picks up an idle loop
# instead of the already-running main asyncio loop, which would cause
# "This event loop is already running" errors.
def run_ws():
import time
import lark_oapi.ws.client as _lark_ws_client
previous_loop = getattr(_lark_ws_client, "loop", None)
ws_loop = asyncio.new_event_loop()
asyncio.set_event_loop(ws_loop)
# Patch the module-level loop used by lark's ws Client.start()
_lark_ws_client.loop = ws_loop
try:
while self._running:
try:
self._ws_client.start()
except Exception as e:
self.logger.warning("WebSocket error: {}", e)
if self._running:
time.sleep(5)
finally:
if getattr(_lark_ws_client, "loop", None) is ws_loop:
_lark_ws_client.loop = previous_loop
with suppress(Exception):
asyncio.set_event_loop(None)
ws_loop.close()
self._ws_thread = threading.Thread(target=run_ws, daemon=True)
self._ws_thread.start()
await self._ws_runner.start_client(self.name, self._ws_client)
# Fetch bot's own open_id for accurate @mention matching
self._bot_open_id = await asyncio.get_running_loop().run_in_executor(
@@ -803,6 +1055,7 @@ class FeishuChannel(BaseChannel):
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
"""
self._running = False
await self._ws_runner.stop_client(self.name)
self.logger.info("bot stopped")
def _fetch_bot_open_id(self) -> str | None:
@@ -1589,13 +1842,95 @@ class FeishuChannel(BaseChannel):
response.msg,
response.get_log_id(),
)
if msg_type == "interactive":
return self._reply_interactive_fallback_sync(
parent_message_id,
content,
reply_in_thread=reply_in_thread,
)
return False
self.logger.debug("reply sent to message {}", parent_message_id)
return True
except Exception:
self.logger.exception("Error replying to message {}", parent_message_id)
if msg_type == "interactive":
return self._reply_interactive_fallback_sync(
parent_message_id,
content,
reply_in_thread=reply_in_thread,
)
return False
@staticmethod
def _interactive_content_to_text(content: str) -> str | None:
try:
payload = json.loads(content)
except (TypeError, json.JSONDecodeError):
return None
parts = [part.strip() for part in _extract_interactive_content(payload) if part.strip()]
text = "\n".join(parts).strip()
return text or None
@staticmethod
def _fallback_text_chunks(text: str, limit: int = 3500) -> list[str]:
text = text.strip()
if not text:
return []
chunks: list[str] = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
split_at = remaining.rfind("\n", 0, limit)
if split_at < limit // 2:
split_at = limit
chunks.append(remaining[:split_at].strip())
remaining = remaining[split_at:].strip()
return [chunk for chunk in chunks if chunk]
def _reply_interactive_fallback_sync(
self,
parent_message_id: str,
content: str,
*,
reply_in_thread: bool = False,
) -> bool:
text = self._interactive_content_to_text(content)
if not text:
return False
sent = False
for chunk in self._fallback_text_chunks(text):
body = json.dumps({"text": chunk}, ensure_ascii=False)
sent = self._reply_message_sync(
parent_message_id,
"text",
body,
reply_in_thread=reply_in_thread,
) or sent
if sent:
self.logger.warning("Sent Feishu interactive reply as text fallback")
return sent
def _send_interactive_fallback_sync(
self,
receive_id_type: str,
receive_id: str,
content: str,
) -> str | None:
text = self._interactive_content_to_text(content)
if not text:
return None
last_message_id: str | None = None
for chunk in self._fallback_text_chunks(text):
body = json.dumps({"text": chunk}, ensure_ascii=False)
message_id = self._send_message_sync(receive_id_type, receive_id, "text", body)
if message_id:
last_message_id = message_id
if last_message_id:
self.logger.warning("Sent Feishu interactive message as text fallback")
return last_message_id
def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool:
"""Return whether a group reply should create a Feishu thread/topic."""
return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message
@@ -1639,6 +1974,12 @@ class FeishuChannel(BaseChannel):
response.msg,
response.get_log_id(),
)
if msg_type == "interactive":
return self._send_interactive_fallback_sync(
receive_id_type,
receive_id,
content,
)
return None
msg_id = getattr(response.data, "message_id", None)
self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id)
@@ -2135,11 +2476,15 @@ class FeishuChannel(BaseChannel):
Sync handler for incoming messages (called from WebSocket thread).
Schedules async handling in the main event loop.
"""
if not self._running:
return
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
async def _on_message(self, data: P2ImMessageReceiveV1) -> None:
"""Handle incoming message from Feishu."""
if not self._running:
return
try:
event = data.event
message = event.message
@@ -2290,9 +2635,9 @@ class FeishuChannel(BaseChannel):
# Private chat: no override — same behavior as Telegram/Slack.
if chat_type == "group":
if self.config.topic_isolation:
session_key = f"feishu:{chat_id}:{root_id or message_id}"
session_key = f"{self.name}:{chat_id}:{root_id or message_id}"
else:
session_key = f"feishu:{chat_id}"
session_key = f"{self.name}:{chat_id}"
else:
session_key = None
+270 -64
View File
@@ -24,6 +24,7 @@ from nanobot.bus.outbound_events import (
replace_outbound_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels._feishu_instances import ChannelInstanceSpec, feishu_instance_specs
from nanobot.channels.base import BaseChannel
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.schema import Config
@@ -61,6 +62,10 @@ def _default_channel_config(name: str) -> dict[str, Any] | None:
def _channel_config_enabled(name: str, section: Any) -> bool:
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True))
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if isinstance(section, dict):
return bool(section.get("enabled", default_enabled))
@@ -104,11 +109,108 @@ class ChannelManager:
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self.channels: dict[str, BaseChannel] = {}
self._channel_tasks: dict[str, asyncio.Task] = {}
self._dispatch_task: asyncio.Task | None = None
self._started = False
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
self._init_channels()
def _config_extra_channel_names(self, config: Config | None = None) -> set[str]:
extra = getattr((config or self.config).channels, "__pydantic_extra__", None) or {}
return set(extra.keys())
def _channel_section(
self,
name: str,
*,
config: Config | None = None,
default_sections: dict[str, Any] | None = None,
) -> Any:
config = config or self.config
section = getattr(config.channels, name, None)
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
return section
if default_sections is None:
return _default_channel_config(name)
if name not in default_sections:
default = _default_channel_config(name)
if default is not None:
default_sections[name] = default
return default_sections.get(name)
def _channel_instance_specs(
self,
name: str,
cls: type[BaseChannel],
section: Any,
*,
enabled_only: bool = True,
) -> list[ChannelInstanceSpec]:
if name == "feishu":
return feishu_instance_specs(
section,
cls.default_config(),
enabled_only=enabled_only,
)
return [
ChannelInstanceSpec(
base_name=name,
instance_id="default",
runtime_name=name,
config=section,
)
]
def _build_channel(
self,
name: str,
cls: type[BaseChannel],
section: Any,
*,
runtime_name: str | None = None,
) -> BaseChannel:
kwargs: dict[str, Any] = {}
if cls.name == "websocket":
from nanobot.channels.websocket import WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
parsed = WebSocketConfig.model_validate(section)
static_path = _default_webui_dist() if self._webui_static_dist else None
workspace = Path(self.config.workspace_path)
gateway = build_gateway_services(
config=parsed,
bus=self.bus,
session_manager=self._session_manager,
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action,
logger=logger,
)
kwargs["gateway"] = gateway
channel = cls(section, self.bus, **kwargs)
if runtime_name and runtime_name != channel.name:
channel.name = runtime_name
channel.send_progress = self._resolve_bool_override(
section, "send_progress", self.config.channels.send_progress,
)
channel.send_tool_hints = self._resolve_bool_override(
section, "send_tool_hints", self.config.channels.send_tool_hints,
)
channel.show_reasoning = self._resolve_bool_override(
section, "show_reasoning", self.config.channels.show_reasoning,
)
return channel
def _init_channels(self) -> None:
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
from nanobot.channels.registry import discover_channel_names, discover_enabled
@@ -118,24 +220,12 @@ class ChannelManager:
# extra="allow"), so we enumerate candidates from pkgutil scan
# (cheap, no imports) and any plugin keys in __pydantic_extra__.
names = discover_channel_names()
candidate_names = set(names)
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
candidate_names.update(extra.keys())
candidate_names = set(names) | self._config_extra_channel_names()
default_sections: dict[str, Any] = {}
def section_for(name: str) -> Any:
section = getattr(self.config.channels, name, None)
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
return section
if name not in default_sections:
default = _default_channel_config(name)
if default is not None:
default_sections[name] = default
return default_sections.get(name)
enabled_names: set[str] = set()
for name in candidate_names:
section = section_for(name)
section = self._channel_section(name, default_sections=default_sections)
if section is None:
continue
if _channel_config_enabled(name, section):
@@ -146,48 +236,18 @@ class ChannelManager:
_names=names,
warn_import_errors=True,
).items():
section = section_for(name)
section = self._channel_section(name, default_sections=default_sections)
if section is None:
continue
try:
kwargs: dict[str, Any] = {}
if cls.name == "websocket":
from nanobot.channels.websocket import WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
parsed = WebSocketConfig.model_validate(section)
static_path = _default_webui_dist() if self._webui_static_dist else None
workspace = Path(self.config.workspace_path)
gateway = build_gateway_services(
config=parsed,
bus=self.bus,
session_manager=self._session_manager,
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
logger=logger,
for spec in self._channel_instance_specs(name, cls, section):
self.channels[spec.runtime_name] = self._build_channel(
name,
cls,
spec.config,
runtime_name=spec.runtime_name,
)
kwargs["gateway"] = gateway
channel = cls(section, self.bus, **kwargs)
channel.send_progress = self._resolve_bool_override(
section, "send_progress", self.config.channels.send_progress,
)
channel.send_tool_hints = self._resolve_bool_override(
section, "send_tool_hints", self.config.channels.send_tool_hints,
)
channel.show_reasoning = self._resolve_bool_override(
section, "show_reasoning", self.config.channels.show_reasoning,
)
self.channels[name] = channel
logger.info("{} channel enabled", cls.display_name)
logger.info("{} channel enabled as {}", cls.display_name, spec.runtime_name)
except Exception as e:
logger.warning("{} channel not available: {}", name, e)
@@ -243,20 +303,173 @@ class ChannelManager:
except Exception:
logger.exception("Failed to start channel {}", name)
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task:
logger.info("Starting {} channel...", name)
task = asyncio.create_task(self._start_channel(name, channel))
self._channel_tasks[name] = task
return task
async def _stop_channel(self, name: str) -> bool:
channel = self.channels.get(name)
if channel is None:
self._channel_tasks.pop(name, None)
return False
task = self._channel_tasks.pop(name, None)
try:
await channel.stop()
logger.info("Stopped {} channel", name)
except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling():
raise
logger.debug("Channel {} stop task was already cancelled", name)
except Exception:
logger.exception("Error stopping {}", name)
if task is not None and not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
return True
def _is_known_channel_name(self, name: str) -> bool:
from nanobot.channels.registry import discover_channel_names, discover_plugins
return name in set(discover_channel_names()) or name in discover_plugins()
def _load_channel_class(self, name: str) -> type[BaseChannel] | None:
from nanobot.channels.registry import discover_channel_names, discover_enabled
names = discover_channel_names()
return discover_enabled({name}, _names=names, warn_import_errors=True).get(name)
async def apply_channel_feature_action(self, action: str, name: str) -> dict[str, Any]:
"""Apply a WebUI channel enable/disable action without restarting the gateway.
Returns a small transport-neutral result. ``handled=False`` means the
optional feature is not a channel and should keep the default feature
response semantics.
"""
name = name.strip()
instance_id = ""
if "." in name:
name, instance_id = name.split(".", 1)
if not name or not self._is_known_channel_name(name):
return {"handled": False}
if name == "websocket":
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": "WebSocket hosts the WebUI and is applied on restart.",
}
from nanobot.config.loader import load_config
self.config = load_config()
section = self._channel_section(name)
if action == "disable":
runtime_names = [name if not instance_id else f"{name}.{instance_id}"]
if name == "feishu" and not instance_id:
runtime_names = [
runtime_name
for runtime_name in self.channels
if runtime_name == "feishu" or runtime_name.startswith("feishu.")
]
stopped = False
for runtime_name in runtime_names:
stopped = await self._stop_channel(runtime_name) or stopped
self.channels.pop(runtime_name, None)
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": f"{name} channel stopped." if stopped else f"{name} channel disabled.",
}
if action != "enable":
return {"handled": True, "ok": False, "requires_restart": True}
if section is None or not _channel_config_enabled(name, section):
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel config was not enabled.",
}
cls = self._load_channel_class(name)
if cls is None:
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel could not be loaded.",
}
specs = self._channel_instance_specs(name, cls, section)
if instance_id:
specs = [spec for spec in specs if spec.instance_id == instance_id]
if not specs:
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel config was not enabled.",
}
try:
built = [
(
spec.runtime_name,
self._build_channel(
name,
cls,
spec.config,
runtime_name=spec.runtime_name,
),
)
for spec in specs
]
except Exception as exc:
logger.exception("Failed to build {} channel after settings change", name)
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel could not be started: {exc}",
}
for runtime_name, _channel in built:
if runtime_name in self.channels:
await self._stop_channel(runtime_name)
for runtime_name, channel in built:
self.channels[runtime_name] = channel
if self._started:
self._start_channel_task(runtime_name, channel)
logger.info("{} channel applied without restart", runtime_name)
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": f"{cls.display_name} channel applied without restart.",
}
async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher."""
if not self.channels:
logger.warning("No channels enabled")
return
self._started = True
# Start outbound dispatcher
self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
# Start channels
tasks = []
for name, channel in self.channels.items():
logger.info("Starting {} channel...", name)
tasks.append(asyncio.create_task(self._start_channel(name, channel)))
tasks.append(self._start_channel_task(name, channel))
self._notify_restart_done_if_needed()
@@ -284,6 +497,7 @@ class ChannelManager:
async def stop_all(self) -> None:
"""Stop all channels and the dispatcher."""
logger.info("Stopping all channels...")
self._started = False
# Stop dispatcher
if self._dispatch_task:
@@ -292,16 +506,8 @@ class ChannelManager:
await self._dispatch_task
# Stop all channels
for name, channel in self.channels.items():
try:
await channel.stop()
logger.info("Stopped {} channel", name)
except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling():
raise
logger.debug("Channel {} stop task was already cancelled", name)
except Exception:
logger.exception("Error stopping {}", name)
for name in list(self.channels):
await self._stop_channel(name)
@staticmethod
def _fingerprint_content(content: str) -> str:
+56 -3
View File
@@ -1,15 +1,17 @@
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
import asyncio
import html
import json
import mimetypes
import re
import sys
import time
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypeAlias
from urllib.parse import quote, urlparse
from urllib.parse import quote, unquote, urlparse
from pydantic import Field
@@ -92,6 +94,19 @@ MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = {
"img": {"src", "alt", "title", "width", "height"},
}
MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"}
_MXC_IMAGE_PLACEHOLDER_PREFIX = "https://nanobot.invalid/matrix-mxc/"
_MXC_MARKDOWN_IMAGE_RE = re.compile(
r"(?P<prefix>!\[[^\]]*\]\()"
r"(?P<value>mxc://[^\s)]+)"
r"(?P<suffix>(?:\s+[^)]*)?\))"
)
_MXC_IMAGE_SRC_RE = re.compile(
r"(?P<prefix>\bsrc=)(?P<quote>[\"'])(?P<value>mxc://[^\"']+)(?P=quote)",
re.IGNORECASE,
)
_MXC_PLACEHOLDER_SRC_RE = re.compile(
rf'src="{re.escape(_MXC_IMAGE_PLACEHOLDER_PREFIX)}([^"]+)"'
)
def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None:
@@ -99,7 +114,10 @@ def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None
if tag == "a" and attr == "href":
return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None
if tag == "img" and attr == "src":
return value if value.lower().startswith("mxc://") else None
lowered = value.lower()
if lowered.startswith("mxc://") or lowered.startswith(_MXC_IMAGE_PLACEHOLDER_PREFIX):
return value
return None
if tag == "code" and attr == "class":
classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")]
return " ".join(classes) if classes else None
@@ -115,6 +133,39 @@ MATRIX_HTML_CLEANER = nh3.Cleaner(
link_rel="noopener noreferrer",
)
def _mask_mxc_markdown_image_sources(text: str) -> str:
def repl(match: re.Match[str]) -> str:
value = quote(match.group("value"), safe="")
return (
f"{match.group('prefix')}"
f"{_MXC_IMAGE_PLACEHOLDER_PREFIX}{value}"
f"{match.group('suffix')}"
)
return _MXC_MARKDOWN_IMAGE_RE.sub(repl, text)
def _mask_mxc_image_sources(rendered_html: str) -> str:
def repl(match: re.Match[str]) -> str:
value = quote(match.group("value"), safe="")
return (
f'{match.group("prefix")}{match.group("quote")}'
f"{_MXC_IMAGE_PLACEHOLDER_PREFIX}{value}"
f'{match.group("quote")}'
)
return _MXC_IMAGE_SRC_RE.sub(repl, rendered_html)
def _unmask_mxc_image_sources(cleaned_html: str) -> str:
def repl(match: re.Match[str]) -> str:
value = html.escape(unquote(match.group(1)), quote=True)
return f'src="{value}"'
return _MXC_PLACEHOLDER_SRC_RE.sub(repl, cleaned_html)
@dataclass
class _StreamBuf:
"""
@@ -135,7 +186,9 @@ class _StreamBuf:
def _render_markdown_html(text: str) -> str | None:
"""Render markdown to sanitized HTML; returns None for plain text."""
try:
formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip()
masked_text = _mask_mxc_markdown_image_sources(text)
rendered = _mask_mxc_image_sources(MATRIX_MARKDOWN(masked_text))
formatted = _unmask_mxc_image_sources(MATRIX_HTML_CLEANER.clean(rendered).strip())
except Exception:
return None
if not formatted:
+6 -2
View File
@@ -10,7 +10,11 @@ from loguru import logger
if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel
_INTERNAL = frozenset({"base", "manager", "registry"})
_INTERNAL = frozenset({
"base",
"manager",
"registry",
})
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
@@ -21,7 +25,7 @@ def discover_channel_names() -> list[str]:
return [
name
for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
if name not in _INTERNAL and not ispkg
if name not in _INTERNAL and not name.startswith("_") and not ispkg
]
+252 -46
View File
@@ -5,6 +5,7 @@ import os
import select
import signal
import sys
import time
from collections.abc import Callable, Iterable
from contextlib import nullcontext, suppress
from pathlib import Path
@@ -75,6 +76,7 @@ from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
from nanobot.config.schema import Config # noqa: E402
from nanobot.security.network import is_loopback_host # noqa: E402
from nanobot.utils.evaluator import evaluate_response # noqa: E402
from nanobot.utils.helpers import sync_workspace_templates # noqa: E402
from nanobot.utils.restart import ( # noqa: E402
@@ -82,6 +84,11 @@ from nanobot.utils.restart import ( # noqa: E402
format_restart_completed_message,
should_show_cli_restart_notice,
)
from nanobot.webui.build import ( # noqa: E402
BuildMode,
WebUIBuildError,
ensure_webui_bundle,
)
from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
@@ -724,25 +731,12 @@ def onboard(
)
def _merge_missing_defaults(existing: Any, defaults: Any) -> Any:
"""Recursively fill in missing values from defaults without overwriting user config."""
if not isinstance(existing, dict) or not isinstance(defaults, dict):
return existing
merged = dict(existing)
for key, value in defaults.items():
if key not in merged:
merged[key] = value
else:
merged[key] = _merge_missing_defaults(merged[key], value)
return merged
def _onboard_plugins(config_path: Path) -> None:
"""Inject default config for all discovered channels (built-in + plugins)."""
import json
from nanobot.channels.registry import discover_all
from nanobot.config.loader import merge_missing_defaults
all_channels = discover_all()
if not all_channels:
@@ -756,7 +750,7 @@ def _onboard_plugins(config_path: Path) -> None:
if name not in channels:
channels[name] = cls.default_config()
else:
channels[name] = _merge_missing_defaults(channels[name], cls.default_config())
channels[name] = merge_missing_defaults(channels[name], cls.default_config())
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
@@ -883,11 +877,7 @@ def _confirm_webui_action(message: str, *, yes: bool) -> None:
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
if yes:
return
try:
interactive = sys.stdin.isatty()
except Exception:
interactive = False
if not interactive:
if not _cli_can_prompt():
console.print(
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
"`nanobot onboard --wizard`.[/red]"
@@ -898,6 +888,19 @@ def _confirm_webui_action(message: str, *, yes: bool) -> None:
raise typer.Exit(1)
def _cli_can_prompt() -> bool:
try:
return sys.stdin.isatty()
except Exception:
return False
def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
if yes:
return "auto"
return "prompt" if _cli_can_prompt() else "warn"
def _resolve_webui_config_path(config: str | None) -> Path:
"""Resolve the config path used by ``nanobot webui`` and bind loader state."""
from nanobot.config.loader import get_config_path, set_config_path
@@ -942,6 +945,43 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
return model.model_dump(by_alias=True, exclude_none=True)
def _webui_channel_enabled(config: Config) -> bool:
from nanobot.channels.websocket import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
return bool(WebSocketConfig.model_validate(current).enabled)
def _prepare_webui_bundle_for_gateway(
config: Config,
*,
mode: BuildMode,
webui_static_dist: bool = True,
) -> None:
"""Refresh or warn about stale bundled WebUI assets before gateway startup."""
if not webui_static_dist or not _webui_channel_enabled(config):
return
def _print(message: str) -> None:
console.print(f"[yellow]{escape(message)}[/yellow]")
def _confirm(message: str) -> bool:
return typer.confirm(message, default=True)
try:
ensure_webui_bundle(
mode=mode,
confirm=_confirm if mode == "prompt" else None,
output=_print,
)
except WebUIBuildError as exc:
if mode == "warn":
console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
return
console.print(f"[red]Error: {escape(str(exc))}[/red]")
raise typer.Exit(1) from exc
def _host_for_local_browser(host: str) -> str:
"""Map bind hosts to a browser-openable local host."""
if host in {"0.0.0.0", ""}:
@@ -1041,7 +1081,6 @@ def _warn_webui_bind_scope(config: Config) -> None:
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
"""Best-effort wait for the WebUI listener before opening a browser."""
import socket
import time
from urllib.parse import urlparse
@@ -1050,11 +1089,75 @@ def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
port = parsed.port or (443 if parsed.scheme == "https" else 80)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=0.2):
return
except OSError:
time.sleep(0.1)
if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
return
time.sleep(0.1)
def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
"""Return whether a local TCP endpoint accepts connections."""
import socket
try:
with socket.create_connection((host, port), timeout=timeout_s):
return True
except OSError:
return False
def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
"""Return whether the nanobot gateway health endpoint responds OK."""
import json
import urllib.error
import urllib.request
browser_host = _host_for_local_browser(host)
try:
with urllib.request.urlopen(
f"http://{browser_host}:{port}/health",
timeout=timeout_s,
) as response:
if response.status != 200:
return False
body = response.read(1024)
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
return False
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return False
return payload.get("status") == "ok"
def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
"""Return whether the WebUI URL's TCP endpoint is already listening."""
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
def _print_foreground_port_conflict(
*,
webui_url: str,
gateway_host: str,
gateway_port: int,
) -> None:
console.print(
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
)
console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
console.print(
f" Gateway health: [cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
)
console.print()
console.print("If this is an existing nanobot instance, use it or stop it first:")
console.print(" [cyan]nanobot gateway status[/cyan]")
console.print(" [cyan]nanobot gateway stop[/cyan]")
console.print("Or choose different ports with [cyan]--port[/cyan] and [cyan]--gateway-port[/cyan].")
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
@@ -1063,11 +1166,41 @@ def _open_webui_browser(url: str, *, wait: bool = True) -> None:
if wait:
_wait_for_webui(url)
display_url = _webui_display_url(url)
try:
webbrowser.open(url)
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{url}[/cyan]")
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
except Exception as exc:
console.print(f"[yellow]Could not open browser ({exc}); visit {url}[/yellow]")
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
"""Explain how the browser and gateway lifecycles differ."""
console.print()
if attached:
console.print("[green]nanobot is attached to the existing gateway.[/green]")
else:
console.print("[green]nanobot is running in this terminal.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
def _attach_to_background_gateway(runtime: Any) -> None:
"""Keep a foreground WebUI command attached to a managed gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
while runtime.status().running:
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping nanobot...[/yellow]")
result = runtime.stop()
if result.ok or result.message == "gateway_not_running":
console.print("[green]Gateway stopped.[/green]")
return
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
raise typer.Exit(1)
console.print("[yellow]Gateway stopped.[/yellow]")
def _gateway_instance_command(
@@ -1191,9 +1324,9 @@ def serve(
port = port if port is not None else api_cfg.port
timeout = timeout if timeout is not None else api_cfg.timeout
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
if host in {"0.0.0.0", "::"} and not api_key:
if not is_loopback_host(host) and not api_key:
console.print(
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
f"[red]Error: host {host} is available beyond this device but api_key is not set. "
"Set api.api_key in config to prevent unauthenticated access.[/red]"
)
raise typer.Exit(1)
@@ -1217,9 +1350,9 @@ def serve(
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
console.print(" [cyan]Session[/cyan] : api:default")
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
if host in {"0.0.0.0", "::"}:
if not is_loopback_host(host):
console.print(
"[yellow]API is bound to all interfaces "
"[yellow]API is available beyond this device "
"(authentication required).[/yellow]"
)
console.print()
@@ -1256,7 +1389,11 @@ def webui(
),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
background: bool = typer.Option(False, "--background", help="Start gateway in the background"),
background: bool = typer.Option(
False,
"--background",
help="Keep the gateway running after this command exits",
),
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
yes: bool = typer.Option(
False,
@@ -1323,21 +1460,25 @@ def webui(
f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
)
if background:
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_arg,
config_path=config_arg,
)
)
start_options = GatewayStartOptions(
port=effective_gateway_port,
webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_arg,
config_path=config_arg,
)
)
start_options = GatewayStartOptions(
port=effective_gateway_port,
workspace=workspace_arg,
config_path=config_arg,
)
if background:
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
result = runtime.start_background(start_options)
restarted = False
restart_attempted = False
@@ -1365,14 +1506,53 @@ def webui(
"View logs: "
f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
)
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print(
"Stop nanobot: "
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url)
return
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
webui_ready = _webui_endpoint_reachable(webui_url)
if gateway_ready and webui_ready:
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
console.print(
"Restart the gateway if you need it to pick up local source changes: "
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(runtime)
else:
console.print(
"[yellow]This gateway is controlled by another foreground command. "
"Stop it from that terminal.[/yellow]"
)
return
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
_host_for_local_browser(runtime_config.gateway.host),
effective_gateway_port,
)
webui_port_taken = webui_ready
if gateway_port_taken or webui_port_taken:
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=runtime_config.gateway.host,
gateway_port=effective_gateway_port,
)
raise typer.Exit(1)
_print_webui_foreground_lifecycle(attached=False)
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else webui_url,
webui_bundle_mode=webui_bundle_mode,
)
@@ -1387,6 +1567,7 @@ def _run_gateway(
port: int | None = None,
open_browser_url: str | None = None,
webui_static_dist: bool = True,
webui_bundle_mode: BuildMode = "warn",
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
@@ -1409,8 +1590,29 @@ def _run_gateway(
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
webui_url = _webui_browser_url(config)
gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
_prepare_webui_bundle_for_gateway(
config,
mode=webui_bundle_mode,
webui_static_dist=webui_static_dist,
)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
runtime_events = RuntimeEventBus()
@@ -1895,6 +2097,10 @@ app.add_typer(
log_handler_id=_log_handler_id,
load_runtime_config=_load_runtime_config,
run_gateway=_run_gateway,
prepare_webui_bundle=lambda config, mode: _prepare_webui_bundle_for_gateway(
config,
mode=mode,
),
),
name="gateway",
)
@@ -2274,7 +2480,7 @@ def plugins_list(
@plugins_app.command("enable")
def plugins_enable(
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"),
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, bedrock)"),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"),
):
+19 -2
View File
@@ -25,11 +25,13 @@ from nanobot.gateway.service import (
GatewayServiceResult,
ServiceManagerKind,
)
from nanobot.webui.build import BuildMode
RuntimeConfigLoader = Callable[[str | None, str | None], Config]
GatewayRunner = Callable[..., None]
GatewayRuntimeFactory = Callable[..., Any]
GatewayServiceFactory = Callable[[], Any]
WebUIBundlePreparer = Callable[[Config, BuildMode], None]
def create_gateway_app(
@@ -40,6 +42,7 @@ def create_gateway_app(
run_gateway: GatewayRunner,
runtime_factory: GatewayRuntimeFactory | None = None,
service_factory: GatewayServiceFactory | None = None,
prepare_webui_bundle: WebUIBundlePreparer | None = None,
) -> typer.Typer:
gateway_app = typer.Typer(
help="Start and manage the nanobot gateway.",
@@ -81,14 +84,20 @@ def create_gateway_app(
def service_installer():
return service_factory() if service_factory is not None else GatewayServiceInstaller()
def interactive_build_mode() -> BuildMode:
# `nanobot gateway` is often launched by tests, supervisors, or service managers.
# The higher-level `nanobot webui` command owns interactive first-run guidance.
return "warn"
def start_options(
*,
port: int | None,
verbose: bool,
workspace: str | None,
config: str | None,
loaded_config: Config | None = None,
) -> GatewayStartOptions:
cfg = load_runtime_config(config, workspace)
cfg = loaded_config or load_runtime_config(config, workspace)
resolved_config = str(Path(config).expanduser().resolve()) if config else None
resolved_workspace = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
return GatewayStartOptions(
@@ -139,6 +148,9 @@ def create_gateway_app(
console.print("[red]Error: --foreground and --background cannot be used together.[/red]")
raise typer.Exit(1)
if background:
cfg = load_runtime_config(config, workspace)
if prepare_webui_bundle is not None:
prepare_webui_bundle(cfg, interactive_build_mode())
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.start_background(
start_options(
@@ -146,6 +158,7 @@ def create_gateway_app(
verbose=verbose,
workspace=workspace,
config=config,
loaded_config=cfg,
)
)
if result.ok:
@@ -158,7 +171,7 @@ def create_gateway_app(
configure_logging(verbose)
cfg = load_runtime_config(config, workspace)
run_gateway(cfg, port=port)
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
@gateway_app.command("status")
def gateway_status(
@@ -211,6 +224,9 @@ def create_gateway_app(
timeout: int = typer.Option(20, "--timeout", help="Restart timeout in seconds"),
) -> None:
"""Restart the background gateway."""
cfg = load_runtime_config(config, workspace)
if prepare_webui_bundle is not None:
prepare_webui_bundle(cfg, interactive_build_mode())
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.restart(
start_options(
@@ -218,6 +234,7 @@ def create_gateway_app(
verbose=verbose,
workspace=workspace,
config=config,
loaded_config=cfg,
),
timeout_s=timeout,
)
+14
View File
@@ -89,6 +89,20 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
json.dump(data, f, indent=2, ensure_ascii=False)
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
"""Recursively add missing defaults without replacing configured values."""
if not isinstance(existing, dict) or not isinstance(defaults, dict):
return existing
merged = dict(existing)
for key, value in defaults.items():
if key not in merged:
merged[key] = value
else:
merged[key] = merge_missing_defaults(merged[key], value)
return merged
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
+24 -406
View File
@@ -1,60 +1,27 @@
"""Background process control for ``nanobot gateway``.
This module intentionally stays small: the CLI owns command wording, while this
runtime owns process state, log files, and platform-specific detach/stop details.
"""
"""Gateway-specific configuration for the shared background process runtime."""
from __future__ import annotations
import ctypes
import json
import os
import signal
import hashlib
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from nanobot.config.paths import get_data_dir
from nanobot.process_runtime import (
ManagedProcessRuntime,
ProcessResult,
ProcessRuntimePaths,
ProcessStartOptions,
ProcessStatus,
)
@dataclass(frozen=True)
class GatewayStartOptions:
"""Options needed to start a background gateway instance."""
port: int
verbose: bool = False
workspace: str | None = None
config_path: str | None = None
@dataclass(frozen=True)
class GatewayStatus:
"""Current background gateway status."""
running: bool
pid: int | None
state_path: Path
log_path: Path
started_at: str | None = None
port: int | None = None
command: tuple[str, ...] = ()
reason: str = "not_started"
@dataclass(frozen=True)
class RuntimeResult:
"""Result from a gateway runtime control operation."""
ok: bool
message: str
status: GatewayStatus
GatewayStartOptions = ProcessStartOptions
GatewayStatus = ProcessStatus
RuntimeResult = ProcessResult
def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]:
@@ -78,14 +45,9 @@ def build_gateway_command(python_executable: str, options: GatewayStartOptions)
@dataclass(frozen=True)
class GatewayRuntimePaths:
class GatewayRuntimePaths(ProcessRuntimePaths):
"""Filesystem layout for one gateway runtime instance."""
run_dir: Path
logs_dir: Path
state_path: Path
log_path: Path
@classmethod
def for_instance(
cls,
@@ -107,9 +69,11 @@ class GatewayRuntimePaths:
)
class GatewayRuntime:
class GatewayRuntime(ManagedProcessRuntime):
"""Manage a background ``nanobot gateway`` process."""
service_name = "gateway"
def __init__(
self,
*,
@@ -120,367 +84,21 @@ class GatewayRuntime:
subprocess_run: Callable[..., Any] = subprocess.run,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.paths = paths or GatewayRuntimePaths.for_instance()
self.platform_name = platform_name or _platform_name()
self.python_executable = python_executable or sys.executable
self._popen = popen
self._subprocess_run = subprocess_run
self._sleep = sleep
@classmethod
def refresh_state_pid(cls, *, paths: GatewayRuntimePaths) -> None:
"""Update the PID in an existing state file to ``os.getpid()``.
Called early in gateway server startup so the state file self-heals
after any restart, regardless of platform or restart mechanism.
"""
if not paths.state_path.exists():
return
try:
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
state["pid"] = os.getpid()
rt = cls(paths=paths)
state["identity"] = rt._process_identity(os.getpid())
state["started_at"] = _utc_now()
rt._write_state(state)
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
"""Start gateway as a detached background process."""
current = self.status()
if current.running:
return RuntimeResult(False, "gateway_already_running", current)
command = self._build_child_command(options)
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
with self.paths.log_path.open("a", encoding="utf-8") as log_handle:
process = self._popen(
command,
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=subprocess.STDOUT,
**self._popen_platform_kwargs(),
)
pid = int(process.pid)
self._sleep(0.2)
if not self._is_pid_running(pid):
return RuntimeResult(False, "gateway_exited_during_startup", self.status())
identity = self._process_identity(pid)
self._write_state(
{
"pid": pid,
"identity": identity,
"started_at": _utc_now(),
"platform": self.platform_name,
"port": options.port,
"workspace": options.workspace,
"config_path": options.config_path,
"command": command,
"log_path": str(self.paths.log_path),
}
)
return RuntimeResult(True, "gateway_started_background", self.status())
def stop(self, *, timeout_s: int = 20) -> RuntimeResult:
"""Stop the recorded background gateway process."""
status = self.status()
if not status.pid:
return RuntimeResult(False, "gateway_not_running", status)
state = self._read_state()
if not self._record_matches_process(state, status.pid):
self._clear_state()
return RuntimeResult(False, "gateway_state_stale", self.status(reason="stale_state"))
if not self._terminate(status.pid, timeout_s=timeout_s):
return RuntimeResult(False, "gateway_stop_timeout", self.status(reason="stop_timeout"))
self._clear_state()
return RuntimeResult(True, "gateway_stopped", self.status(reason="stopped"))
def restart(self, options: GatewayStartOptions, *, timeout_s: int = 20) -> RuntimeResult:
"""Restart the background gateway."""
stop_result = self.stop(timeout_s=timeout_s)
if not stop_result.ok and stop_result.message not in {"gateway_not_running", "gateway_state_stale"}:
return stop_result
return self.start_background(options)
def status(self, *, reason: str | None = None) -> GatewayStatus:
"""Return live status, clearing stale state when needed."""
state = self._read_state()
pid = _as_int(state.get("pid")) if state else None
if pid is None:
return GatewayStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "not_started",
)
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
self._clear_state()
return GatewayStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "stale_state",
)
command = state.get("command")
return GatewayStatus(
running=True,
pid=pid,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
started_at=_as_str(state.get("started_at")),
port=_as_int(state.get("port")),
command=tuple(command) if isinstance(command, list) else (),
reason=reason or "running",
super().__init__(
paths=paths or GatewayRuntimePaths.for_instance(),
platform_name=platform_name,
python_executable=python_executable,
popen=popen,
subprocess_run=subprocess_run,
sleep=sleep,
)
def read_log_tail(self, *, tail: int = 200) -> list[str]:
"""Return the last ``tail`` log lines."""
if tail <= 0 or not self.paths.log_path.exists():
return []
try:
lines = self.paths.log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
return lines[-tail:]
def follow_logs(self, *, tail: int = 200) -> int:
"""Print existing log tail and follow new log lines."""
for line in self.read_log_tail(tail=tail):
print(line)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
self.paths.log_path.touch(exist_ok=True)
try:
with self.paths.log_path.open("r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
while True:
line = handle.readline()
if line:
print(line.rstrip("\n"))
else:
self._sleep(0.5)
except KeyboardInterrupt:
return 130
def _build_child_command(self, options: GatewayStartOptions) -> list[str]:
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
return build_gateway_command(self.python_executable, options)
def _popen_platform_kwargs(self) -> dict[str, Any]:
if self.platform_name == "Windows":
flags = 0
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
return {"creationflags": flags}
return {"start_new_session": True}
def _terminate(self, pid: int, *, timeout_s: int) -> bool:
if self.platform_name == "Windows":
return self._terminate_windows(pid, timeout_s=timeout_s)
return self._terminate_posix(pid, timeout_s=timeout_s)
def _terminate_posix(self, pid: int, *, timeout_s: int) -> bool:
try:
pgid = os.getpgid(pid)
except OSError:
pgid = None
try:
if pgid is not None:
os.killpg(pgid, signal.SIGTERM)
else:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return True
if self._wait_for_exit(pid, timeout_s):
return True
with suppress(ProcessLookupError):
if pgid is not None:
os.killpg(pgid, signal.SIGKILL)
else:
os.kill(pid, signal.SIGKILL)
return self._wait_for_exit(pid, 2)
def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool:
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
if ctrl_break is not None:
# Detached Windows children can reject CTRL_BREAK_EVENT with WinError 87;
# keep the existing taskkill fallback for that process shape.
ctrl_break_sent = False
try:
os.kill(pid, ctrl_break)
except ProcessLookupError:
return True
except OSError:
pass
else:
ctrl_break_sent = True
if ctrl_break_sent and self._wait_for_exit(pid, timeout_s):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if self._wait_for_exit(pid, 2):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return self._wait_for_exit(pid, 2)
def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool:
deadline = time.monotonic() + max(float(timeout_s), 0.0)
while time.monotonic() < deadline:
if not self._is_pid_running(pid):
return True
self._sleep(0.1)
return not self._is_pid_running(pid)
def _is_pid_running(self, pid: int) -> bool:
if pid <= 0:
return False
if self.platform_name == "Windows":
return _windows_process_identity(pid) is not None
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _process_identity(self, pid: int) -> str | int | None:
if self.platform_name == "Windows":
return _windows_process_identity(pid)
try:
return os.getpgid(pid)
except OSError:
return None
def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool:
if not state:
return False
recorded = state.get("identity")
if recorded is None:
return True
return recorded == self._process_identity(pid)
def _read_state(self) -> dict[str, Any] | None:
try:
with self.paths.state_path.open(encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _write_state(self, payload: dict[str, Any]) -> None:
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f"{self.paths.state_path.name}.",
suffix=".tmp",
dir=self.paths.run_dir,
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(self.paths.state_path)
finally:
tmp_path.unlink(missing_ok=True)
def _clear_state(self) -> None:
self.paths.state_path.unlink(missing_ok=True)
def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | None:
raw = "|".join(value for value in (workspace, config_path) if value)
if not raw:
return None
import hashlib
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
def _platform_name() -> str:
if sys.platform.startswith("win"):
return "Windows"
if sys.platform == "darwin":
return "Darwin"
return "Linux"
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _as_int(value: object) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
return None
return None
def _as_str(value: object) -> str | None:
return value if isinstance(value, str) else None
def _windows_process_identity(pid: int) -> str | None:
if os.name != "nt":
return None
class FileTime(ctypes.Structure):
_fields_ = [("low", ctypes.c_uint32), ("high", ctypes.c_uint32)]
@property
def value(self) -> int:
return (int(self.high) << 32) | int(self.low)
process_query_limited_information = 0x1000
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
if not handle:
return None
try:
creation_time = FileTime()
exit_time = FileTime()
kernel_time = FileTime()
user_time = FileTime()
ok = kernel32.GetProcessTimes(
handle,
ctypes.byref(creation_time),
ctypes.byref(exit_time),
ctypes.byref(kernel_time),
ctypes.byref(user_time),
)
if not ok:
return None
exit_code = ctypes.c_uint32()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return None
if exit_code.value != 259:
return None
return str(creation_time.value)
finally:
kernel32.CloseHandle(handle)
+237 -28
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import subprocess
import sys
from contextlib import suppress
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
@@ -13,7 +14,19 @@ from loguru import logger
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from nanobot.channels._feishu_instances import (
DEFAULT_INSTANCE_ID,
feishu_instance_specs,
set_feishu_instance_enabled,
)
from nanobot.channels._setup import (
channel_field_value,
channel_setup_spec,
channel_value_present,
stringify_channel_value,
)
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.loader import merge_missing_defaults
from nanobot.config.schema import Config
@@ -35,6 +48,8 @@ class InstallResult:
_INSTALL_TIMEOUT_SECONDS = 300
_LOG_OUTPUT_LIMIT = 4000
_HIDDEN_OPTIONAL_FEATURES = {"documents", "langsmith", "pdf"}
_BUNDLED_FEATURE_ALIASES = {"documents", "pdf"}
def load_pyproject(path: Path) -> dict[str, Any]:
@@ -78,9 +93,13 @@ def optional_dependency_groups() -> dict[str, list[str] | None]:
return {
name: list(values)
for name, values in deps.items()
if name != "dev" and isinstance(values, list)
if name != "dev" and name not in _HIDDEN_OPTIONAL_FEATURES and isinstance(values, list)
}
return optional_dependency_groups_from_metadata()
return {
name: values
for name, values in optional_dependency_groups_from_metadata().items()
if name not in _HIDDEN_OPTIONAL_FEATURES
}
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
@@ -260,16 +279,6 @@ def write_config_data(path: Path, data: dict[str, Any]) -> None:
json.dump(data, f, indent=2, ensure_ascii=False)
def merge_missing_defaults(existing: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
merged = dict(defaults)
for key, value in existing.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_missing_defaults(value, merged[key])
else:
merged[key] = value
return merged
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
@@ -282,6 +291,21 @@ def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[s
write_config_data(config_path, data)
def enable_feishu_instance_config(
config_path: Path,
defaults: dict[str, Any],
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get("feishu", {})
if not isinstance(existing, dict):
existing = {}
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, True)
write_config_data(config_path, data)
def disable_channel_config(config_path: Path, channel_name: str) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
@@ -293,8 +317,27 @@ def disable_channel_config(config_path: Path, channel_name: str) -> None:
write_config_data(config_path, data)
def disable_feishu_instance_config(
config_path: Path,
defaults: dict[str, Any],
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get("feishu", {})
if not isinstance(existing, dict):
existing = {}
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, False)
write_config_data(config_path, data)
def channel_enabled(config: Config, name: str) -> bool:
section = getattr(config.channels, name, None)
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True))
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if section is None:
return default_enabled
@@ -303,6 +346,97 @@ def channel_enabled(config: Config, name: str) -> bool:
return bool(getattr(section, "enabled", default_enabled))
def _channel_config_snapshot(section: Any, name: str) -> tuple[dict[str, str], list[str]]:
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
return {}, []
spec = channel_setup_spec(name)
if spec is None:
return {}, []
values: dict[str, str] = {}
configured_fields: list[str] = []
for field in spec.snapshot_fields:
value = channel_field_value(section, field)
if not channel_value_present(value):
continue
key = f"channels.{name}.{field}"
configured_fields.append(key)
if field in spec.secrets:
continue
values[key] = stringify_channel_value(value)
return values, configured_fields
def _channel_has_required_setup(section: Any, name: str) -> bool:
spec = channel_setup_spec(name)
return bool(spec and spec.is_configured(section))
def _local_login_state_present(section: Any, name: str) -> bool:
"""Return whether a QR-login channel has reusable local account state."""
from nanobot.config.loader import get_config_path
if name == "weixin":
configured_dir = channel_field_value(section, "stateDir")
state_dir = (
Path(str(configured_dir)).expanduser()
if configured_dir
else get_config_path().parent / "weixin"
)
try:
payload = json.loads((state_dir / "account.json").read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return False
return bool(str(payload.get("token") or "").strip())
if name == "whatsapp":
configured_path = channel_field_value(section, "databasePath")
database_path = (
Path(str(configured_path)).expanduser()
if configured_path
else get_config_path().parent / "whatsapp-auth" / "neonize.db"
)
try:
return database_path.is_file() and database_path.stat().st_size > 0
except OSError:
return False
return False
def _feishu_instance_display_name(config: dict[str, Any]) -> str:
display_name = str(config.get("displayName") or "").strip()
if display_name:
return display_name
local_name = str(config.get("name") or "").strip()
return local_name or "nanobot"
def channel_configured(config: Config, name: str) -> bool:
"""Return whether a channel has enough saved setup to be enabled directly."""
section = getattr(config.channels, name, None)
if name in {"weixin", "whatsapp"} and _local_login_state_present(section, name):
return True
if section is None:
return False
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return any(
_channel_has_required_setup(instance.config, "feishu")
for instance in feishu_instance_specs(section, FeishuChannel.default_config())
)
spec = channel_setup_spec(name)
if not spec or not spec.required:
return channel_enabled(config, name)
return _channel_has_required_setup(section, name)
def optional_features_payload(
*,
config: Config | None = None,
@@ -311,7 +445,14 @@ def optional_features_payload(
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config
config_provided = config is not None
config = config or load_config()
if not config_provided:
with suppress(Exception):
from nanobot.channels.feishu import refresh_saved_feishu_identities
if refresh_saved_feishu_identities(config):
config = load_config()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
@@ -321,21 +462,53 @@ def optional_features_payload(
is_channel = name in builtin_channels or name in plugin_channels
installed = extra_installed(name, extras[name]) if name in extras else True
enabled = channel_enabled(config, name) if is_channel else installed
configured = channel_configured(config, name) if is_channel else installed
ready = bool(enabled and installed)
status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled"
features.append(
{
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": is_channel or name in extras,
}
)
feature = {
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"configured": configured,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": _feature_requires_restart(name, is_channel=is_channel),
}
if is_channel:
config_values, configured_fields = _channel_config_snapshot(
getattr(config.channels, name, None),
name,
)
if config_values:
feature["config_values"] = config_values
if configured_fields:
feature["configured_fields"] = configured_fields
if name == "feishu" and is_channel:
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(
getattr(config.channels, "feishu", None),
FeishuChannel.default_config(),
)
feature["instances"] = [
{
"id": spec.instance_id,
"name": spec.config.get("name") or "nanobot",
"display_name": _feishu_instance_display_name(spec.config),
"avatar_url": spec.config.get("avatarUrl") or "",
"domain": spec.config.get("domain") or "feishu",
"enabled": bool(spec.config.get("enabled", False)),
"configured": _channel_has_required_setup(spec.config, "feishu"),
"app_id": spec.config.get("appId") or spec.config.get("app_id") or "",
"group_policy": spec.config.get("groupPolicy") or "mention",
"allow_from": list(spec.config.get("allowFrom") or []),
}
for spec in specs
]
features.append(feature)
payload = {
"features": features,
@@ -351,6 +524,7 @@ def enable_optional_feature(
*,
config_path: Path | None = None,
allow_install: bool = True,
instance_id: str = DEFAULT_INSTANCE_ID,
runner: Any = run_install_command,
) -> dict[str, Any]:
from nanobot.channels.registry import (
@@ -360,6 +534,20 @@ def enable_optional_feature(
)
from nanobot.config.loader import get_config_path
# The old extra never powered a runtime integration. Keep the CLI spelling
# as a compatibility alias while directing users to the supported tracer.
if name == "langsmith":
name = "langfuse"
if name in _BUNDLED_FEATURE_ALIASES:
payload = optional_features_payload(
last_action={
"ok": True,
"message": f"Feature '{name}' is included with nanobot",
"enabled": True,
}
)
payload["requires_restart"] = False
return payload
config_path = config_path or get_config_path()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
@@ -394,7 +582,10 @@ def enable_optional_feature(
f"Channel '{name}' is not importable after enable: {exc}",
status=500,
) from exc
enable_channel_config(config_path, name, channel_cls.default_config())
if name == "feishu":
enable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
else:
enable_channel_config(config_path, name, channel_cls.default_config())
message = f"Enabled channel '{name}'"
elif name in plugin_channels:
enable_channel_config(config_path, name, plugin_channels[name].default_config())
@@ -403,14 +594,26 @@ def enable_optional_feature(
message = f"Enabled feature '{name}'"
payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True})
payload["requires_restart"] = bool(name in builtin_channels or name in plugin_channels or name in extras)
payload["requires_restart"] = _feature_requires_restart(
name,
is_channel=name in builtin_channels or name in plugin_channels,
)
return payload
def _feature_requires_restart(name: str, *, is_channel: bool) -> bool:
"""Return whether an installed feature needs the running engine rebuilt."""
if is_channel:
return True
# These libraries are imported lazily or used by a newly spawned service.
return name not in {"api", "documents", "pdf", "olostep"}
def disable_optional_feature(
name: str,
*,
config_path: Path | None = None,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import get_config_path
@@ -426,7 +629,13 @@ def disable_optional_feature(
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
if name not in known_channels:
raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400)
disable_channel_config(config_path, name)
if name == "feishu":
from nanobot.channels.registry import load_channel_class
channel_cls = load_channel_class(name)
disable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
else:
disable_channel_config(config_path, name)
payload = optional_features_payload(
last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False}
)
+4
View File
@@ -2,6 +2,7 @@
from nanobot.pairing.store import (
approve_code,
clear_channel,
deny_code,
format_expiry,
format_pairing_reply,
@@ -11,6 +12,7 @@ from nanobot.pairing.store import (
is_approved,
list_pending,
revoke,
revoke_channel,
)
# Metadata keys used by channels and commands to tag pairing-related messages.
@@ -19,6 +21,7 @@ PAIRING_COMMAND_META_KEY = "_pairing_command"
__all__ = [
"approve_code",
"clear_channel",
"deny_code",
"format_expiry",
"format_pairing_reply",
@@ -28,6 +31,7 @@ __all__ = [
"is_approved",
"list_pending",
"revoke",
"revoke_channel",
"PAIRING_CODE_META_KEY",
"PAIRING_COMMAND_META_KEY",
]
+48 -3
View File
@@ -170,7 +170,52 @@ def revoke(channel: str, sender_id: str) -> bool:
_save(data)
logger.info("Revoked {} from {}", sid, channel)
return True
return False
return False
def revoke_channel(channel: str) -> int:
"""Remove all approved sender IDs for *channel*.
Returns the number of approved senders that were removed.
"""
with _LOCK:
data = _load()
approved: dict[str, set[str]] = data.get("approved", {})
users = approved.pop(channel, set())
if not users:
return 0
_save(data)
logger.info("Revoked {} approved sender(s) from {}", len(users), channel)
return len(users)
def clear_channel(channel: str) -> dict[str, int]:
"""Remove approved senders and pending requests for *channel*."""
with _LOCK:
data = _load()
approved: dict[str, set[str]] = data.get("approved", {})
approved_users = approved.pop(channel, set())
pending: dict[str, Any] = data.get("pending", {})
pending_codes = [
code
for code, info in pending.items()
if str(info.get("channel", "")) == channel
]
for code in pending_codes:
del pending[code]
if not approved_users and not pending_codes:
return {"approved": 0, "pending": 0}
_save(data)
logger.info(
"Cleared {} approved sender(s) and {} pending request(s) from {}",
len(approved_users),
len(pending_codes),
channel,
)
return {"approved": len(approved_users), "pending": len(pending_codes)}
def get_approved(channel: str) -> list[str]:
@@ -185,8 +230,8 @@ def format_pairing_reply(code: str) -> str:
return (
"Hi there! This assistant only responds to approved users.\n\n"
f"Your pairing code is: `{code}`\n\n"
"To get access, ask the owner to approve this code:\n"
f"- In this chat: send `/pairing approve {code}`"
"To get access, ask the owner to approve this request in the nanobot WebUI.\n"
f"If the WebUI is not available, the owner can also send `/pairing approve {code}`."
)
+456
View File
@@ -0,0 +1,456 @@
"""Cross-platform lifecycle management for nanobot background processes."""
from __future__ import annotations
import ctypes
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from filelock import FileLock
@dataclass(frozen=True)
class ProcessStartOptions:
"""Options shared by managed nanobot processes."""
port: int
verbose: bool = False
workspace: str | None = None
config_path: str | None = None
@dataclass(frozen=True)
class ProcessStatus:
"""Current state of one managed process."""
running: bool
pid: int | None
state_path: Path
log_path: Path
started_at: str | None = None
port: int | None = None
command: tuple[str, ...] = ()
reason: str = "not_started"
@dataclass(frozen=True)
class ProcessResult:
"""Result of a managed process control operation."""
ok: bool
message: str
status: ProcessStatus
@dataclass(frozen=True)
class ProcessRuntimePaths:
"""Filesystem state used to track one managed process."""
run_dir: Path
logs_dir: Path
state_path: Path
log_path: Path
class ManagedProcessRuntime:
"""Manage a detached child process without service-specific policy."""
service_name = "process"
def __init__(
self,
*,
paths: ProcessRuntimePaths,
platform_name: str | None = None,
python_executable: str | None = None,
popen: Callable[..., Any] = subprocess.Popen,
subprocess_run: Callable[..., Any] = subprocess.run,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.paths = paths
self.platform_name = platform_name or _platform_name()
self.python_executable = python_executable or sys.executable
self._popen = popen
self._subprocess_run = subprocess_run
self._sleep = sleep
@classmethod
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
"""Update a managed state file after the recorded process restarts."""
if not paths.state_path.exists():
return
try:
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
state["pid"] = os.getpid()
runtime = cls(paths=paths)
state["identity"] = runtime._process_identity(os.getpid())
state["started_at"] = _utc_now()
runtime._write_state(state)
def start_background(self, options: ProcessStartOptions) -> ProcessResult:
"""Start the configured command as a detached process."""
with self._lifecycle_lock():
return self._start_background(options)
def _start_background(self, options: ProcessStartOptions) -> ProcessResult:
current = self.status()
if current.running:
return ProcessResult(False, self._message("already_running"), current)
command = self._build_child_command(options)
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
with self.paths.log_path.open("a", encoding="utf-8") as log_handle:
process = self._popen(
command,
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=subprocess.STDOUT,
**self._popen_platform_kwargs(),
)
pid = int(process.pid)
self._sleep(0.2)
if not self._is_pid_running(pid):
return ProcessResult(False, self._message("exited_during_startup"), self.status())
self._write_state(
{
"pid": pid,
"identity": self._process_identity(pid),
"started_at": _utc_now(),
"platform": self.platform_name,
"port": options.port,
"workspace": options.workspace,
"config_path": options.config_path,
"command": command,
"log_path": str(self.paths.log_path),
}
)
return ProcessResult(True, self._message("started_background"), self.status())
def stop(self, *, timeout_s: int = 20) -> ProcessResult:
"""Stop the process recorded in this runtime's state file."""
with self._lifecycle_lock():
return self._stop(timeout_s=timeout_s)
def _stop(self, *, timeout_s: int) -> ProcessResult:
status = self.status()
if not status.pid:
return ProcessResult(False, self._message("not_running"), status)
state = self._read_state()
if not self._record_matches_process(state, status.pid):
self._clear_state()
return ProcessResult(
False,
self._message("state_stale"),
self.status(reason="stale_state"),
)
if not self._terminate(status.pid, timeout_s=timeout_s):
final_status = self.status(reason="stop_timeout")
if final_status.running:
return ProcessResult(
False,
self._message("stop_timeout"),
final_status,
)
return ProcessResult(True, self._message("stopped"), final_status)
self._clear_state()
return ProcessResult(True, self._message("stopped"), self.status(reason="stopped"))
def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> ProcessResult:
"""Restart the managed process."""
with self._lifecycle_lock():
stop_result = self._stop(timeout_s=timeout_s)
recoverable = {self._message("not_running"), self._message("state_stale")}
if not stop_result.ok and stop_result.message not in recoverable:
return stop_result
return self._start_background(options)
def status(self, *, reason: str | None = None) -> ProcessStatus:
"""Return live status, clearing stale state when needed."""
state = self._read_state()
pid = _as_int(state.get("pid")) if state else None
if pid is None:
return ProcessStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "not_started",
)
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
self._clear_state()
return ProcessStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "stale_state",
)
command = state.get("command")
return ProcessStatus(
running=True,
pid=pid,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
started_at=_as_str(state.get("started_at")),
port=_as_int(state.get("port")),
command=tuple(command) if isinstance(command, list) else (),
reason=reason or "running",
)
def read_log_tail(self, *, tail: int = 200) -> list[str]:
"""Return the last ``tail`` log lines."""
if tail <= 0 or not self.paths.log_path.exists():
return []
try:
lines = self.paths.log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
return lines[-tail:]
def follow_logs(self, *, tail: int = 200) -> int:
"""Print existing log lines and follow new output."""
for line in self.read_log_tail(tail=tail):
print(line)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
self.paths.log_path.touch(exist_ok=True)
try:
with self.paths.log_path.open("r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
while True:
line = handle.readline()
if line:
print(line.rstrip("\n"))
else:
self._sleep(0.5)
except KeyboardInterrupt:
return 130
def _message(self, event: str) -> str:
return f"{self.service_name}_{event}"
def _lifecycle_lock(self) -> FileLock:
lock_path = self.paths.state_path.with_name(f"{self.paths.state_path.name}.lock")
return FileLock(str(lock_path))
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
raise NotImplementedError
def _popen_platform_kwargs(self) -> dict[str, Any]:
if self.platform_name == "Windows":
flags = 0
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
return {"creationflags": flags}
return {"start_new_session": True}
def _terminate(self, pid: int, *, timeout_s: int) -> bool:
if self.platform_name == "Windows":
return self._terminate_windows(pid, timeout_s=timeout_s)
return self._terminate_posix(pid, timeout_s=timeout_s)
def _terminate_posix(self, pid: int, *, timeout_s: int) -> bool:
try:
pgid = os.getpgid(pid)
except OSError:
pgid = None
try:
if pgid is not None:
os.killpg(pgid, signal.SIGTERM)
else:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return True
if self._wait_for_exit(pid, timeout_s):
return True
with suppress(ProcessLookupError, PermissionError):
if pgid is not None:
os.killpg(pgid, signal.SIGKILL)
else:
os.kill(pid, signal.SIGKILL)
return self._wait_for_exit(pid, 2)
def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool:
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
if ctrl_break is not None:
ctrl_break_sent = False
try:
os.kill(pid, ctrl_break)
except ProcessLookupError:
return True
except OSError:
pass
else:
ctrl_break_sent = True
if ctrl_break_sent and self._wait_for_exit(pid, timeout_s):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if self._wait_for_exit(pid, 2):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return self._wait_for_exit(pid, 2)
def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool:
deadline = time.monotonic() + max(float(timeout_s), 0.0)
while time.monotonic() < deadline:
if not self._is_pid_running(pid):
return True
self._sleep(0.1)
return not self._is_pid_running(pid)
def _is_pid_running(self, pid: int) -> bool:
if pid <= 0:
return False
if self.platform_name == "Windows":
return _windows_process_identity(pid) is not None
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _process_identity(self, pid: int) -> str | int | None:
if self.platform_name == "Windows":
return _windows_process_identity(pid)
try:
return os.getpgid(pid)
except OSError:
return None
def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool:
if not state:
return False
recorded = state.get("identity")
if recorded is None:
return True
return recorded == self._process_identity(pid)
def _read_state(self) -> dict[str, Any] | None:
try:
with self.paths.state_path.open(encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _write_state(self, payload: dict[str, Any]) -> None:
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f"{self.paths.state_path.name}.",
suffix=".tmp",
dir=self.paths.run_dir,
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(self.paths.state_path)
finally:
tmp_path.unlink(missing_ok=True)
def _clear_state(self) -> None:
self.paths.state_path.unlink(missing_ok=True)
def _platform_name() -> str:
if sys.platform.startswith("win"):
return "Windows"
if sys.platform == "darwin":
return "Darwin"
return "Linux"
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _as_int(value: object) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
return None
return None
def _as_str(value: object) -> str | None:
return value if isinstance(value, str) else None
def _windows_process_identity(pid: int) -> str | None:
if os.name != "nt":
return None
class FileTime(ctypes.Structure):
_fields_ = [("low", ctypes.c_uint32), ("high", ctypes.c_uint32)]
@property
def value(self) -> int:
return (int(self.high) << 32) | int(self.low)
process_query_limited_information = 0x1000
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
if not handle:
return None
try:
creation_time = FileTime()
exit_time = FileTime()
kernel_time = FileTime()
user_time = FileTime()
ok = kernel32.GetProcessTimes(
handle,
ctypes.byref(creation_time),
ctypes.byref(exit_time),
ctypes.byref(kernel_time),
ctypes.byref(user_time),
)
if not ok:
return None
exit_code = ctypes.c_uint32()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return None
if exit_code.value != 259:
return None
return str(creation_time.value)
finally:
kernel32.CloseHandle(handle)
+54
View File
@@ -18,6 +18,16 @@ from typing import Any
from pydantic.alias_generators import to_snake
@dataclass(frozen=True)
class ProviderModelSpec:
"""A curated model exposed by providers without a model-list endpoint."""
id: str
label: str = ""
description: str = ""
context_window: int | None = None
@dataclass(frozen=True)
class ProviderSpec:
"""One LLM provider's metadata. See PROVIDERS below for real examples.
@@ -33,6 +43,8 @@ class ProviderSpec:
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
display_name: str = "" # shown in `nanobot status`
model_catalog: str = "auto" # WebUI model-list source
builtin_models: tuple[ProviderModelSpec, ...] = ()
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
# which provider implementation to use
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
@@ -198,6 +210,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("opencode/", "opencode_zen", "opencode-zen"),
env_key="OPENCODE_API_KEY",
display_name="OpenCode Zen",
settings_alias_for="opencode",
backend="openai_compat",
is_gateway=True,
detect_by_base_keyword="opencode.ai/zen",
@@ -361,6 +374,47 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("openai-codex",),
env_key="",
display_name="OpenAI Codex",
model_catalog="builtin",
builtin_models=(
ProviderModelSpec(
id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.",
context_window=372000,
),
ProviderModelSpec(
id="openai-codex/gpt-5.6-terra",
label="GPT-5.6-Terra",
description="Balanced agentic coding model for everyday work.",
context_window=372000,
),
ProviderModelSpec(
id="openai-codex/gpt-5.6-luna",
label="GPT-5.6-Luna",
description="Fast and affordable agentic coding model.",
context_window=372000,
),
ProviderModelSpec(
id="openai-codex/gpt-5.5",
label="GPT-5.5",
description="Frontier model for complex coding, research, and real-world work.",
),
ProviderModelSpec(
id="openai-codex/gpt-5.4",
label="GPT-5.4",
description="Strong model for everyday coding.",
),
ProviderModelSpec(
id="openai-codex/gpt-5.4-mini",
label="GPT-5.4-Mini",
description="Small, fast, and cost-efficient model for simpler coding tasks.",
),
ProviderModelSpec(
id="openai-codex/gpt-5.3-codex-spark",
label="GPT-5.3-Codex-Spark",
description="Ultra-fast coding model.",
),
),
backend="openai_codex",
detect_by_base_keyword="codex",
default_api_base="https://chatgpt.com/backend-api",
+12
View File
@@ -29,6 +29,18 @@ _URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
def is_loopback_host(host: str) -> bool:
"""Return whether a bind target is explicitly limited to loopback."""
normalized = host.strip().rstrip(".").lower()
if normalized == "localhost":
return True
if normalized.startswith("[") and normalized.endswith("]"):
normalized = normalized[1:-1]
with suppress(ValueError):
return ipaddress.ip_address(normalized).is_loopback
return False
def configure_ssrf_whitelist(cidrs: list[str]) -> None:
"""Allow specific CIDR ranges to bypass SSRF blocking (e.g. Tailscale's 100.64.0.0/10)."""
global _allowed_networks
+1 -1
View File
@@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path:
**Question 2 — Optional dependencies:**
```
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, documents, feishu, matrix, mochat, msteams, napcat, qq, slack, telegram, wecom, weixin, langsmith, pdf"
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, feishu, langfuse, matrix, mochat, msteams, napcat, olostep, qq, slack, telegram, wecom, weixin"
```
Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names.
+176 -26
View File
@@ -1,7 +1,9 @@
"""Document text extraction utilities for nanobot."""
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from zipfile import BadZipFile, ZipFile
from loguru import logger
@@ -37,6 +39,60 @@ SUPPORTED_EXTENSIONS: set[str] = {
}
_MAX_TEXT_LENGTH = 200_000
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
_MAX_OFFICE_UNCOMPRESSED_SIZE = 256 * 1024 * 1024 # 256 MB
_MAX_OFFICE_MEMBER_SIZE = 128 * 1024 * 1024 # 128 MB
_MAX_PDF_CONTENT_STREAM_SIZE = 32 * 1024 * 1024 # 32 MB per page
_MAX_PDF_ATTACHMENT_PAGES = 100
class _TextCollector:
"""Build bounded parser output without retaining the full document text."""
def __init__(self, limit: int) -> None:
self.limit = limit
self.parts: list[str] = []
self.length = 0
self.truncated = False
def add(self, text: str, *, separator: str = "") -> bool:
if not text:
return True
prefix = separator if self.parts else ""
chunk = prefix + text
remaining = self.limit - self.length
if len(chunk) > remaining:
if remaining > 0:
self.parts.append(chunk[:remaining])
self.length += remaining
self.truncated = True
return False
self.parts.append(chunk)
self.length += len(chunk)
return True
def render(self) -> str:
text = "".join(self.parts)
if self.truncated:
text += f"... (truncated at {self.limit} chars)"
return text
class PdfSafetyError(Exception):
"""Raised when a PDF exceeds a parser safety boundary."""
class PdfPageRangeError(Exception):
"""Raised when a requested PDF page range is invalid."""
@dataclass(frozen=True, slots=True)
class PdfExtraction:
text: str
total_pages: int
start_page: int
end_page: int
def extract_text(path: Path) -> str | None:
@@ -54,12 +110,16 @@ def extract_text(path: Path) -> str | None:
if not path.exists():
return f"[error: file not found: {path}]"
try:
if path.stat().st_size > _MAX_EXTRACT_FILE_SIZE:
return f"[error: file exceeds {_MAX_EXTRACT_FILE_SIZE // (1024 * 1024)} MB limit]"
except OSError as e:
return f"[error: failed to inspect file: {e!s}]"
ext = path.suffix.lower()
# Document formats -- each branch lazily imports its parser so that
# startup does not pay the ~25 MB cost of loading openpyxl /
# python-docx / python-pptx / pypdf up front (see issue #3422).
# Parsers stay lazy even though they are bundled so idle processes do not
# retain their import cost (see issue #3422).
if ext == ".pdf":
return _extract_pdf(path)
elif ext == ".docx":
@@ -81,21 +141,71 @@ def extract_text(path: Path) -> str | None:
def _extract_pdf(path: Path) -> str:
"""Extract text from PDF using pypdf."""
try:
from pypdf import PdfReader
except ImportError:
return "[error: pypdf not installed]"
try:
reader = PdfReader(path)
pages: list[str] = []
for i, page in enumerate(reader.pages, 1):
text = page.extract_text() or ""
pages.append(f"--- Page {i} ---\n{text}")
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
result = extract_pdf_pages(
path,
max_pages=_MAX_PDF_ATTACHMENT_PAGES,
max_chars=_MAX_TEXT_LENGTH,
)
text = result.text
if result.end_page < result.total_pages - 1:
text += f"\n\n(Showing pages 1-{result.end_page + 1} of {result.total_pages}.)"
return text
except Exception as e:
logger.exception("Failed to extract PDF {}", path)
return f"[error: failed to extract PDF: {e!s}]"
def extract_pdf_pages(
path: Path,
*,
pages: str | None = None,
max_pages: int = _MAX_PDF_ATTACHMENT_PAGES,
max_chars: int = _MAX_TEXT_LENGTH,
) -> PdfExtraction:
"""Extract a bounded PDF page range using the bundled pypdf reader."""
from pypdf import PdfReader
reader = PdfReader(path, strict=False)
total_pages = len(reader.pages)
if total_pages == 0:
return PdfExtraction("", 0, 0, -1)
start, end = _parse_pdf_page_range(pages, total_pages)
end = min(end, start + max_pages - 1)
collector = _TextCollector(max_chars)
for index in range(start, end + 1):
page = reader.pages[index]
contents = page.get_contents()
if contents is not None:
stream_size = len(contents.get_data())
if stream_size > _MAX_PDF_CONTENT_STREAM_SIZE:
raise PdfSafetyError(
f"page {index + 1} content stream exceeds "
f"{_MAX_PDF_CONTENT_STREAM_SIZE // (1024 * 1024)} MB limit"
)
text = (page.extract_text() or "").strip()
if text and not collector.add(f"--- Page {index + 1} ---\n{text}", separator="\n\n"):
end = index
break
return PdfExtraction(collector.render(), total_pages, start, end)
def _parse_pdf_page_range(pages: str | None, total_pages: int) -> tuple[int, int]:
if not pages:
return 0, total_pages - 1
values = pages.strip().split("-")
if len(values) not in {1, 2}:
raise PdfPageRangeError(f"invalid page range: {pages}")
try:
start = int(values[0])
end = int(values[-1])
except ValueError as e:
raise PdfPageRangeError(f"invalid page range: {pages}") from e
if start < 1 or end < start or start > total_pages:
raise PdfPageRangeError(f"invalid page range: {pages}")
return start - 1, min(end, total_pages) - 1
def _extract_docx(path: Path) -> str:
"""Extract text from DOCX using python-docx."""
try:
@@ -103,9 +213,15 @@ def _extract_docx(path: Path) -> str:
except ImportError:
return "[error: python-docx not installed]"
try:
if error := _office_archive_error(path):
return error
doc = DocxDocument(path)
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
collector = _TextCollector(_MAX_TEXT_LENGTH)
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
if text and not collector.add(text, separator="\n\n"):
break
return collector.render()
except Exception as e:
logger.exception("Failed to extract DOCX {}", path)
return f"[error: failed to extract DOCX: {e!s}]"
@@ -118,19 +234,27 @@ def _extract_xlsx(path: Path) -> str:
except ImportError:
return "[error: openpyxl not installed]"
try:
if error := _office_archive_error(path):
return error
wb = load_workbook(path, read_only=True, data_only=True)
try:
sheets: list[str] = []
collector = _TextCollector(_MAX_TEXT_LENGTH)
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
rows: list[str] = []
wrote_header = False
for row in ws.iter_rows(values_only=True):
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
if row_text.strip():
rows.append(row_text)
if rows:
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
if not wrote_header:
if not collector.add(
f"--- Sheet: {sheet_name} ---",
separator="\n\n",
):
return collector.render()
wrote_header = True
if not collector.add(row_text, separator="\n"):
return collector.render()
return collector.render()
finally:
wb.close()
except Exception as e:
@@ -145,15 +269,21 @@ def _extract_pptx(path: Path) -> str:
except ImportError:
return "[error: python-pptx not installed]"
try:
if error := _office_archive_error(path):
return error
prs = PptxPresentation(path)
slides: list[str] = []
collector = _TextCollector(_MAX_TEXT_LENGTH)
for i, slide in enumerate(prs.slides, 1):
slide_text: list[str] = []
for shape in slide.shapes:
_collect_pptx_shape_text(shape, slide_text)
if slide_text:
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
if not collector.add(
f"--- Slide {i} ---\n" + "\n".join(slide_text),
separator="\n\n",
):
break
return collector.render()
except Exception as e:
logger.exception("Failed to extract PPTX {}", path)
return f"[error: failed to extract PPTX: {e!s}]"
@@ -184,6 +314,28 @@ def _collect_pptx_shape_text(shape, out: list[str]) -> None:
out.append(text)
def _office_archive_error(path: Path) -> str | None:
"""Reject oversized or encrypted OOXML containers before parsing XML."""
try:
with ZipFile(path) as archive:
members = archive.infolist()
except (BadZipFile, OSError) as e:
return f"[error: invalid Office document: {e!s}]"
if len(members) > _MAX_OFFICE_ARCHIVE_MEMBERS:
return f"[error: Office document contains too many files ({len(members)})]"
total_size = 0
for member in members:
if member.flag_bits & 0x1:
return "[error: encrypted Office documents are not supported]"
if member.file_size > _MAX_OFFICE_MEMBER_SIZE:
return "[error: Office document contains an oversized internal file]"
total_size += member.file_size
if total_size > _MAX_OFFICE_UNCOMPRESSED_SIZE:
limit_mb = _MAX_OFFICE_UNCOMPRESSED_SIZE / (1024 * 1024)
return f"[error: Office document expands beyond the {limit_mb:g} MB safety limit]"
return None
def _extract_text_file(path: Path) -> str:
"""Extract text from a plain text file."""
try:
@@ -228,8 +380,6 @@ def _is_text_extension(ext: str) -> bool:
# High-level helper: split media into images + extracted document text
# ---------------------------------------------------------------------------
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
def is_image_file(path: str) -> bool:
"""Check whether *path* looks like an image file.
+3 -2
View File
@@ -3,6 +3,7 @@
The ``dist/`` subdirectory holds the production WebUI bundle served by the
gateway. It is shipped inside the published wheel and is rebuilt automatically
by the ``webui-build`` Hatch hook during ``python -m build``. In an editable
source checkout it stays empty until you run ``cd webui && bun run build``
(or use the Vite dev server at ``cd webui && bun run dev``).
source checkout, ``nanobot webui`` and ``nanobot gateway`` detect stale local
frontend sources and can rebuild the bundle before serving it. WebUI developers
can still use the Vite dev server at ``cd webui && bun run dev``.
"""
+296
View File
@@ -0,0 +1,296 @@
"""Helpers for keeping the bundled WebUI build in sync with source checkouts."""
from __future__ import annotations
import os
import shutil
import subprocess
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
BuildMode = Literal["auto", "prompt", "warn", "skip"]
_SOURCE_TOP_LEVEL_FILES = (
"index.html",
"package.json",
"bun.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"vite.config.ts",
"vite.config.js",
"tailwind.config.ts",
"tailwind.config.js",
"postcss.config.ts",
"postcss.config.js",
"tsconfig.json",
"tsconfig.build.json",
"components.json",
)
_SOURCE_DIRS = ("src", "public")
class WebUIBuildError(RuntimeError):
"""Raised when the local WebUI bundle cannot be built."""
@dataclass(frozen=True)
class WebUIBundleStatus:
"""Freshness status for a source checkout's bundled WebUI assets."""
source_dir: Path
dist_dir: Path
index_html: Path
source_available: bool
dist_available: bool
stale: bool
reason: str
newest_source: Path | None = None
newest_source_mtime_ns: int | None = None
dist_mtime_ns: int | None = None
@property
def needs_build(self) -> bool:
return self.source_available and self.stale
def default_project_root() -> Path:
"""Return the repository root when running from a source checkout."""
return Path(__file__).resolve().parents[2]
def default_webui_source_dir(project_root: Path | None = None) -> Path:
"""Return the conventional frontend source directory for a checkout."""
root = project_root or default_project_root()
return root / "webui"
def default_webui_dist_dir(project_root: Path | None = None) -> Path:
"""Return the bundled WebUI dist directory for the installed package."""
try:
import nanobot.web as web_pkg # type: ignore[import-not-found]
except ImportError:
root = project_root or default_project_root()
return root / "nanobot" / "web" / "dist"
return Path(web_pkg.__file__).resolve().parent / "dist"
def iter_webui_source_files(source_dir: Path) -> list[Path]:
"""Return WebUI source files that should make the production bundle stale."""
files: list[Path] = []
for name in _SOURCE_TOP_LEVEL_FILES:
candidate = source_dir / name
if candidate.is_file():
files.append(candidate)
for dirname in _SOURCE_DIRS:
root = source_dir / dirname
if not root.is_dir():
continue
files.extend(path for path in root.rglob("*") if path.is_file())
return files
def inspect_webui_bundle(
*,
source_dir: Path | None = None,
dist_dir: Path | None = None,
) -> WebUIBundleStatus:
"""Inspect whether a checkout's WebUI source is newer than the bundled dist."""
resolved_source = source_dir or default_webui_source_dir()
resolved_dist = dist_dir or default_webui_dist_dir()
index_html = resolved_dist / "index.html"
if not (resolved_source / "package.json").is_file():
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=False,
dist_available=index_html.is_file(),
stale=False,
reason="no_source",
)
if not index_html.is_file():
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=True,
dist_available=False,
stale=True,
reason="missing_dist",
)
dist_mtime_ns = index_html.stat().st_mtime_ns
newest_source: Path | None = None
newest_source_mtime_ns: int | None = None
for candidate in iter_webui_source_files(resolved_source):
try:
mtime_ns = candidate.stat().st_mtime_ns
except OSError:
continue
if newest_source_mtime_ns is None or mtime_ns > newest_source_mtime_ns:
newest_source = candidate
newest_source_mtime_ns = mtime_ns
if newest_source_mtime_ns is not None and newest_source_mtime_ns > dist_mtime_ns:
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=True,
dist_available=True,
stale=True,
reason="source_newer",
newest_source=newest_source,
newest_source_mtime_ns=newest_source_mtime_ns,
dist_mtime_ns=dist_mtime_ns,
)
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=True,
dist_available=True,
stale=False,
reason="fresh",
newest_source=newest_source,
newest_source_mtime_ns=newest_source_mtime_ns,
dist_mtime_ns=dist_mtime_ns,
)
def describe_webui_bundle_status(status: WebUIBundleStatus) -> str:
"""Return a short user-facing freshness message."""
if status.reason == "missing_dist":
return "Bundled WebUI build is missing."
if status.reason == "source_newer":
changed = _display_source_path(status)
return f"WebUI source is newer than the bundled build ({changed})."
if status.reason == "fresh":
return "Bundled WebUI build is up to date."
return "WebUI source tree was not found; using the bundled build."
def build_webui_bundle(
*,
source_dir: Path | None = None,
dist_dir: Path | None = None,
runner: str | None = None,
subprocess_run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
output: Callable[[str], None] | None = None,
) -> WebUIBundleStatus:
"""Install frontend dependencies and build the WebUI bundle."""
resolved_source = source_dir or default_webui_source_dir()
command_runner = runner or pick_webui_build_runner()
if command_runner is None:
raise WebUIBuildError(
"neither `bun` nor `npm` is available on PATH; install one or run "
"`cd webui && bun run build` manually"
)
_emit(output, f"Building bundled WebUI with `{command_runner}`...")
_run_frontend_command(
[command_runner, "install"],
cwd=resolved_source,
subprocess_run=subprocess_run,
)
_run_frontend_command(
[command_runner, "run", "build"],
cwd=resolved_source,
subprocess_run=subprocess_run,
)
return inspect_webui_bundle(source_dir=resolved_source, dist_dir=dist_dir)
def ensure_webui_bundle(
*,
mode: BuildMode,
source_dir: Path | None = None,
dist_dir: Path | None = None,
confirm: Callable[[str], bool] | None = None,
output: Callable[[str], None] | None = None,
runner: str | None = None,
environ: Mapping[str, str] | None = None,
subprocess_run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
) -> WebUIBundleStatus:
"""Ensure or warn about a stale WebUI bundle according to the selected mode."""
env = environ or os.environ
status = inspect_webui_bundle(source_dir=source_dir, dist_dir=dist_dir)
if not status.needs_build:
return status
detail = describe_webui_bundle_status(status)
if env.get("NANOBOT_SKIP_WEBUI_BUILD") == "1" or mode == "skip":
_emit(output, f"Warning: {detail} Skipping WebUI build.")
return status
if mode == "warn":
_emit(
output,
f"Warning: {detail} Run `cd {status.source_dir} && bun run build` "
"to refresh it.",
)
return status
if mode == "prompt":
if confirm is None:
_emit(output, f"Warning: {detail} No interactive confirmation is available.")
return status
message = "Build WebUI now? This runs `cd webui && bun run build`."
if not confirm(message):
_emit(output, "Continuing with the existing bundled WebUI build.")
return status
try:
return build_webui_bundle(
source_dir=status.source_dir,
dist_dir=status.dist_dir,
runner=runner,
subprocess_run=subprocess_run,
output=output,
)
except WebUIBuildError as exc:
raise WebUIBuildError(f"{detail} {exc}") from exc
def pick_webui_build_runner() -> str | None:
"""Pick the frontend package manager used to build the WebUI."""
for candidate in ("bun", "npm"):
if shutil.which(candidate):
return candidate
return None
def _run_frontend_command(
command: list[str],
*,
cwd: Path,
subprocess_run: Callable[..., subprocess.CompletedProcess],
) -> None:
try:
subprocess_run(command, cwd=cwd, check=True)
except subprocess.CalledProcessError as exc:
raise WebUIBuildError(
f"command failed ({exc.returncode}): {' '.join(command)}"
) from exc
except OSError as exc:
raise WebUIBuildError(f"command failed: {' '.join(command)} ({exc})") from exc
def _display_source_path(status: WebUIBundleStatus) -> str:
if status.newest_source is None:
return "source files changed"
with suppress(ValueError):
return str(status.newest_source.relative_to(status.source_dir))
return str(status.newest_source)
def _emit(output: Callable[[str], None] | None, message: str) -> None:
if output is not None:
output(message)
+435
View File
@@ -0,0 +1,435 @@
"""Short-lived WebUI channel connection sessions."""
from __future__ import annotations
import json
import secrets
import time
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
import httpx
from nanobot.channels import feishu
from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID, validate_instance_id
from nanobot.config.loader import load_config
class ChannelConnectError(Exception):
"""User-facing channel connect failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(slots=True)
class FeishuConnectSession:
id: str
instance_id: str
instance_name: str
device_code: str
qr_url: str
domain: str
interval: int
expire_in: int
created_wall: float
deadline: float
last_error: str | None = None
class FeishuConnectStore:
"""In-memory Feishu/Lark QR connection state.
Sessions intentionally live only in the gateway process and expire quickly.
The app secret is never returned to the browser; it is saved directly to
config when Feishu/Lark completes authorization.
"""
def __init__(self) -> None:
self._sessions: dict[str, FeishuConnectSession] = {}
def start(
self,
*,
domain: str = "feishu",
instance_id: str = DEFAULT_INSTANCE_ID,
mode: str = "replace",
) -> dict[str, Any]:
domain = _normalize_domain(domain)
instance_id = _resolve_instance_id(instance_id, mode)
self._cleanup()
try:
feishu._init_registration(domain)
begin = feishu._begin_registration(domain)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
raise ChannelConnectError(
f"Unable to start Feishu/Lark connection: {exc}",
status=502,
) from exc
session_id = secrets.token_urlsafe(18)
now_wall = time.time()
now = time.monotonic()
expire_in = int(begin["expire_in"])
interval = max(2, int(begin["interval"]))
session = FeishuConnectSession(
id=session_id,
instance_id=instance_id,
instance_name=_default_instance_name(instance_id),
device_code=str(begin["device_code"]),
qr_url=str(begin["qr_url"]),
domain=domain,
interval=interval,
expire_in=expire_in,
created_wall=now_wall,
deadline=now + expire_in,
)
self._sessions[session_id] = session
return _start_payload(session)
def poll(self, session_id: str) -> dict[str, Any]:
self._cleanup()
session = self._sessions.get(session_id)
if session is None:
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
if time.monotonic() >= session.deadline:
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
try:
result = feishu.poll_registration_once(
device_code=session.device_code,
domain=session.domain,
)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
session.last_error = str(exc)
return _pending_payload(session)
session.domain = str(result.get("domain") or session.domain)
status = result.get("status")
if status == "succeeded":
feishu.save_registration_result(
result,
instance_id=session.instance_id,
name=session.instance_name,
)
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "succeeded",
"message": "Feishu is connected.",
"domain": session.domain,
"app_id": result.get("app_id"),
}
if status == "failed":
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "failed",
"message": "Authorization was cancelled or expired.",
"domain": session.domain,
}
return _pending_payload(session)
def cancel(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
"status": "cancelled",
"message": "Feishu connection cancelled.",
}
def _cleanup(self) -> None:
now = time.monotonic()
expired = [session_id for session_id, session in self._sessions.items() if now >= session.deadline]
for session_id in expired:
self._sessions.pop(session_id, None)
def _normalize_domain(domain: str) -> str:
normalized = domain.strip().lower()
return normalized if normalized in {"feishu", "lark"} else "feishu"
def _resolve_instance_id(instance_id: str, mode: str) -> str:
if mode == "create":
return f"assistant-{secrets.token_hex(3)}"
try:
return validate_instance_id(instance_id or DEFAULT_INSTANCE_ID)
except ValueError as exc:
raise ChannelConnectError(str(exc), status=400) from exc
def _default_instance_name(instance_id: str) -> str:
return "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"
def _start_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"qr_url": session.qr_url,
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Scan with Feishu or Lark to connect.",
}
def _pending_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Waiting for authorization.",
}
@dataclass(slots=True)
class WeixinConnectSession:
id: str
qrcode_id: str
qr_url: str
channel: Any
current_poll_base_url: str
refresh_count: int
created_wall: float
deadline: float
last_error: str | None = None
class WeixinConnectStore:
"""In-memory WeChat QR login sessions for the WebUI.
WeChat login writes local account state only after scan confirmation. A
cancelled or expired browser flow leaves any existing account state intact.
"""
def __init__(self) -> None:
self._sessions: dict[str, WeixinConnectSession] = {}
async def start(self, *, force: bool = False) -> dict[str, Any]:
await self._cleanup()
channel = self._build_channel()
if force:
# Start a fresh login flow without touching the currently working
# account. A confirmed scan replaces it via _save_state;
# cancellation or expiry must leave the old account usable.
channel._token = ""
channel._get_updates_buf = ""
elif channel._load_state():
return {
"session_id": "",
"status": "succeeded",
"message": "WeChat is already connected.",
"interval_ms": 2000,
}
channel._client = httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=30),
follow_redirects=True,
)
channel._running = True
try:
qrcode_id, qr_url = await channel._fetch_qr_code()
except Exception as exc:
await self._close_channel(channel)
raise ChannelConnectError(f"Unable to start WeChat QR login: {exc}", status=502) from exc
session_id = secrets.token_urlsafe(18)
now_wall = time.time()
self._sessions[session_id] = WeixinConnectSession(
id=session_id,
qrcode_id=qrcode_id,
qr_url=qr_url,
channel=channel,
current_poll_base_url=channel.config.base_url,
refresh_count=0,
created_wall=now_wall,
deadline=time.monotonic() + 600,
)
return self._start_payload(self._sessions[session_id])
async def poll(self, session_id: str) -> dict[str, Any]:
await self._cleanup()
session = self._sessions.get(session_id)
if session is None:
return {
"session_id": session_id,
"status": "expired",
"message": "This WeChat login has expired. Start again.",
}
try:
status_data = await session.channel._api_get_with_base(
base_url=session.current_poll_base_url,
endpoint="ilink/bot/get_qrcode_status",
params={"qrcode": session.qrcode_id},
auth=False,
)
except Exception as exc:
if session.channel._is_retryable_qr_poll_error(exc):
session.last_error = str(exc)
return self._pending_payload(session)
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": f"WeChat QR login failed: {exc}",
}
if not isinstance(status_data, dict):
return self._pending_payload(session)
status = status_data.get("status", "")
if status == "confirmed":
token = str(status_data.get("bot_token", "") or "")
if not token:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": "WeChat confirmed the scan but returned no token.",
}
base_url = str(status_data.get("baseurl", "") or "")
session.channel._token = token
if base_url:
session.channel.config.base_url = base_url
session.channel._save_state()
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "succeeded",
"message": "WeChat is connected.",
"account": str(status_data.get("ilink_user_id", "") or ""),
}
if status == "scaned_but_redirect":
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
if redirect_host:
redirected_base = (
redirect_host
if redirect_host.startswith(("http://", "https://"))
else f"https://{redirect_host}"
)
session.current_poll_base_url = redirected_base
return self._pending_payload(session)
if status == "expired":
from nanobot.channels.weixin import MAX_QR_REFRESH_COUNT
session.refresh_count += 1
if session.refresh_count > MAX_QR_REFRESH_COUNT:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "expired",
"message": "This WeChat QR code expired. Start again.",
}
try:
session.qrcode_id, session.qr_url = await session.channel._fetch_qr_code()
except Exception as exc:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": f"Could not refresh WeChat QR code: {exc}",
}
session.current_poll_base_url = session.channel.config.base_url
return self._pending_payload(session)
return self._pending_payload(session)
async def cancel(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(session_id, None)
if session is not None:
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "cancelled",
"message": "WeChat login cancelled.",
}
async 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:
session = self._sessions.pop(session_id, None)
if session is not None:
await self._close_channel(session.channel)
@staticmethod
def _build_channel() -> Any:
from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin import WeixinChannel
section = getattr(load_config().channels, "weixin", None)
if hasattr(section, "model_dump"):
config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
config = dict(section)
else:
config = {}
return WeixinChannel(config, MessageBus())
@staticmethod
async def _close_channel(channel: Any) -> None:
channel._running = False
client = getattr(channel, "_client", None)
if client is not None:
with suppress(Exception):
await client.aclose()
channel._client = None
@staticmethod
def _start_payload(session: WeixinConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"status": "pending",
"qr_url": session.qr_url,
"interval_ms": 2000,
"expires_at_ms": int((session.created_wall + 600) * 1000),
"message": "Scan with WeChat to connect.",
}
@staticmethod
def _pending_payload(session: WeixinConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"status": "pending",
"qr_url": session.qr_url,
"interval_ms": 2000,
"expires_at_ms": int((session.created_wall + 600) * 1000),
"message": "Waiting for WeChat scan.",
}
+541
View File
@@ -0,0 +1,541 @@
"""Best-effort Channel setup validation for the WebUI.
Validation is intentionally non-authoritative: it helps the UI explain whether a
channel looks ready, but it never writes config and it does not replace runtime
channel startup semantics.
"""
from __future__ import annotations
import re
import socket
import ssl
from datetime import UTC, datetime
from typing import Any
import httpx
from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import load_config
from nanobot.security.network import resolve_url_target
CheckStatus = str
SetupStatus = str
_TIMEOUT_SECONDS = 4.0
def _official_action(name: str) -> str | None:
spec = channel_setup_spec(name)
return spec.official_url if spec is not None else None
def validate_channel_config(
name: str,
raw_values: dict[str, Any] | None = None,
*,
instance_id: str = "default",
) -> dict[str, Any]:
"""Validate a channel setup without mutating persisted config."""
channel = (name or "").strip()
if not channel:
return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")])
config = load_config()
section = getattr(config.channels, channel, None)
values = _channel_config(channel, section, instance_id=instance_id)
values = _merge_form_values(channel, values, raw_values or {})
validator = _VALIDATORS.get(channel, _validate_generic)
if channel == "email":
payload = _validate_email(
channel,
values,
allow_loopback=config.tools.webui_allow_local_service_access,
)
else:
payload = validator(channel, values)
payload["name"] = channel
return payload
def _validate_websocket(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks = [
_check(
"managed",
"Managed by WebUI",
"pass",
"The browser workbench prepares the local WebSocket channel.",
action_url=_official_action(name),
)
]
return _payload(name, "connected" if _enabled(values) else "configured", checks, can_enable=True)
def _validate_telegram(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
token = _str(values.get("token"))
if token:
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
checks.append(_check("token_format", "Token format", "fail", "Telegram tokens look like 123456:ABC..."))
else:
checks.append(_check("token_format", "Token format", "pass", "Looks like a BotFather token."))
try:
data = _http_get(f"https://api.telegram.org/bot{token}/getMe")
if data.get("ok") and isinstance(data.get("result"), dict):
bot = data["result"]
identity = {
"name": bot.get("username") or bot.get("first_name"),
"account": str(bot.get("id") or ""),
}
checks.append(_check("get_me", "Bot identity", "pass", "Telegram accepted the bot token."))
return _payload(name, "connected", checks, identity=identity, missing_fields=missing)
checks.append(_check("get_me", "Bot identity", "fail", _message_from_response(data, "Telegram rejected the token.")))
except httpx.HTTPStatusError as exc:
checks.append(
_check(
"get_me",
"Bot identity",
"warn",
f"Telegram could not verify the token: HTTP {exc.response.status_code}.",
)
)
except Exception:
checks.append(
_check(
"get_me",
"Bot identity",
"warn",
"Could not reach Telegram now. Try again later.",
)
)
return _status_from_checks(name, checks, missing)
def _validate_discord(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
token = _str(values.get("token"))
if token:
try:
data = _http_get(
"https://discord.com/api/v10/users/@me",
headers={"Authorization": f"Bot {token}"},
)
bot_id = str(data.get("id") or "")
checks.append(_check("bot_token", "Bot token", "pass", "Discord accepted the bot token."))
identity = {
"name": data.get("global_name") or data.get("username"),
"account": bot_id,
}
if bot_id:
checks.append(
_check(
"invite",
"Server invite",
"pass",
"Use this generated OAuth URL to invite the bot.",
action_url=(
"https://discord.com/oauth2/authorize"
f"?client_id={bot_id}&scope=bot%20applications.commands"
),
)
)
return _payload(name, "connected", checks, identity=identity, missing_fields=missing)
except httpx.HTTPStatusError as exc:
checks.append(_check("bot_token", "Bot token", "fail", f"Discord rejected the token: HTTP {exc.response.status_code}"))
except Exception as exc:
checks.append(_check("bot_token", "Bot token", "warn", f"Could not reach Discord now: {exc}"))
return _status_from_checks(name, checks, missing)
def _validate_slack(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
app_token = _str(values.get("appToken"))
bot_token = _str(values.get("botToken"))
if app_token:
checks.append(
_check(
"app_token_prefix",
"Socket Mode app token",
"pass" if app_token.startswith("xapp-") else "fail",
"App-level Socket Mode tokens start with xapp-.",
action_url=_official_action(name),
)
)
if bot_token:
checks.append(
_check(
"bot_token_prefix",
"Bot token",
"pass" if bot_token.startswith("xoxb-") else "fail",
"Bot tokens start with xoxb- after installing the Slack app.",
action_url=_official_action(name),
)
)
if bot_token.startswith("xoxb-"):
try:
data = _http_post(
"https://slack.com/api/auth.test",
headers={"Authorization": f"Bearer {bot_token}"},
)
if data.get("ok"):
identity = {
"name": data.get("user"),
"workspace": data.get("team"),
"account": data.get("user_id"),
}
checks.append(_check("auth_test", "Workspace identity", "pass", "Slack accepted the bot token."))
status = "connected" if app_token.startswith("xapp-") else "configured"
return _payload(name, status, checks, identity=identity, missing_fields=missing)
checks.append(_check("auth_test", "Workspace identity", "fail", _message_from_response(data, "Slack rejected the bot token.")))
except Exception as exc:
checks.append(_check("auth_test", "Workspace identity", "warn", f"Could not reach Slack now: {exc}"))
return _status_from_checks(name, checks, missing)
def _validate_email(
name: str,
values: dict[str, Any],
*,
allow_loopback: bool = False,
) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
if _truthy(values.get("consentGranted")):
checks.append(_check("consent", "Mailbox consent", "pass", "Consent is enabled for this mailbox."))
else:
checks.append(_check("consent", "Mailbox consent", "fail", "Grant consent before nanobot reads this mailbox."))
for prefix, default_port in (("imap", 993), ("smtp", 587)):
host = _str(values.get(f"{prefix}Host"))
port = _int(values.get(f"{prefix}Port")) or default_port
if not host:
continue
if port <= 0 or port > 65535:
checks.append(_check(f"{prefix}_port", f"{prefix.upper()} port", "fail", "Port must be between 1 and 65535."))
continue
checks.append(_check(f"{prefix}_settings", f"{prefix.upper()} settings", "pass", f"{host}:{port} is set."))
try:
_probe_tcp(host, port, allow_loopback=allow_loopback)
checks.append(_check(f"{prefix}_reachability", f"{prefix.upper()} reachability", "pass", "The server accepted a TCP connection."))
except Exception as exc:
checks.append(_check(f"{prefix}_reachability", f"{prefix.upper()} reachability", "warn", f"Could not verify network reachability now: {exc}"))
identity = {"account": _str(values.get("fromAddress") or values.get("imapUsername") or values.get("smtpUsername"))}
return _status_from_checks(name, checks, missing, identity=identity)
def _validate_feishu(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
display_name = _str(values.get("displayName") or values.get("name"))
avatar_url = _str(values.get("avatarUrl"))
if _str(values.get("appId")).startswith(("cli_", "oapi_")):
checks.append(_check("app_id", "App ID", "pass", "A Feishu/Lark App ID is saved."))
elif _str(values.get("appId")):
checks.append(_check("app_id", "App ID", "warn", "App ID is saved, but it does not look like a standard Feishu App ID."))
status = "connected" if not missing else "needs_setup"
identity = {
"name": display_name or "Feishu assistant",
"avatar_url": avatar_url or None,
"account": _str(values.get("appId")),
}
return _payload(name, status, checks, identity=identity, missing_fields=missing)
def _validate_matrix(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
password = _str(values.get("password"))
access_token = _str(values.get("accessToken"))
device_id = _str(values.get("deviceId"))
if password:
checks.append(_check("login", "Login credentials", "pass", "Password login is configured."))
elif access_token and device_id:
checks.append(
_check(
"login",
"Login credentials",
"pass",
"Access token login is configured with its device ID.",
)
)
else:
if not password and not access_token:
missing.append("password_or_accessToken")
message = "Add a password, or an access token with its device ID."
else:
missing.append("deviceId")
message = "A device ID is required with an access token."
checks.append(_check("login", "Login credentials", "fail", message))
checks.append(
_check(
"manual_review",
"Matrix account",
"skipped",
"Room access is verified when the channel starts.",
)
)
return _status_from_checks(name, checks, list(dict.fromkeys(missing)))
def _validate_cli_handoff(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks: list[dict[str, Any]] = []
if _enabled(values) or _str(values.get("token")) or _str(values.get("databasePath")):
checks.append(_check("local_state", "Local login state", "pass", "Saved local login state was detected."))
return _payload(name, "configured", checks, can_enable=True)
checks.append(
_check(
"terminal_login",
"Terminal login",
"skipped",
"This channel uses a terminal QR login flow.",
action_url=_official_action(name),
)
)
return _payload(name, "needs_setup", checks, missing_fields=["terminal_login"], can_enable=False)
def _validate_generic(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
spec = channel_setup_spec(name)
if spec is not None and spec.required:
checks.append(_check("manual_review", "Manual setup", "skipped", "This channel can be checked from saved fields, but not fully verified in-browser."))
return _status_from_checks(name, checks, missing)
if _enabled(values):
return _payload(name, "configured", [_check("enabled", "Enabled", "pass", "This channel is enabled.")])
return _payload(name, "unsupported", [_check("support", "WebUI setup", "skipped", "This channel is not configurable from the WebUI yet.")])
_VALIDATORS = {
"websocket": _validate_websocket,
"telegram": _validate_telegram,
"discord": _validate_discord,
"slack": _validate_slack,
"email": _validate_email,
"feishu": _validate_feishu,
"matrix": _validate_matrix,
"whatsapp": _validate_cli_handoff,
"weixin": _validate_cli_handoff,
}
def _channel_config(name: str, section: Any, *, instance_id: str) -> dict[str, Any]:
if name == "feishu":
try:
from nanobot.channels._feishu_instances import feishu_instance_specs
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(section, FeishuChannel.default_config())
selected = next((spec for spec in specs if spec.instance_id == instance_id), None)
return dict(selected.config) if selected is not None else {}
except Exception:
return {}
if hasattr(section, "model_dump"):
return dict(section.model_dump(mode="json", by_alias=True))
if isinstance(section, dict):
return dict(section)
return {}
def _merge_form_values(
name: str,
values: dict[str, Any],
raw_values: dict[str, Any],
) -> dict[str, Any]:
merged = dict(values)
prefix = f"channels.{name}."
spec = channel_setup_spec(name)
secrets = spec.secrets if spec is not None else frozenset()
for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key:
continue
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
if field in secrets and not _str(raw_value):
continue
_assign(merged, field, raw_value)
return merged
def _required_checks(name: str, values: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
checks: list[dict[str, Any]] = []
missing: list[str] = []
spec = channel_setup_spec(name)
for field in spec.simple_required_fields if spec is not None else ():
value = _get(values, field)
if field == "consentGranted":
if not _truthy(value):
missing.append(field)
continue
if _str(value):
checks.append(_check(f"field:{field}", _label(field), "pass", "Configured."))
else:
missing.append(field)
checks.append(_check(f"field:{field}", _label(field), "fail", "Required."))
return checks, missing
def _status_from_checks(
name: str,
checks: list[dict[str, Any]],
missing: list[str],
*,
identity: dict[str, Any] | None = None,
) -> dict[str, Any]:
if missing:
return _payload(name, "needs_setup", checks, identity=identity, missing_fields=missing, can_enable=False)
if any(check["status"] == "fail" for check in checks):
return _payload(name, "invalid", checks, identity=identity, missing_fields=missing, can_enable=False)
if any(check["status"] == "warn" for check in checks) or any(check["status"] == "skipped" for check in checks):
return _payload(name, "configured", checks, identity=identity, missing_fields=missing)
return _payload(name, "connected", checks, identity=identity, missing_fields=missing)
def _payload(
name: str,
status: SetupStatus,
checks: list[dict[str, Any]],
*,
identity: dict[str, Any] | None = None,
missing_fields: list[str] | None = None,
can_enable: bool | None = None,
) -> dict[str, Any]:
missing = missing_fields or []
return {
"name": name,
"status": status,
"checks": checks,
"identity": {key: value for key, value in (identity or {}).items() if value},
"missing_fields": missing,
"can_enable": status not in {"needs_setup", "invalid", "unsupported"} and not missing
if can_enable is None
else can_enable,
"requires_restart": False,
"checked_at": datetime.now(UTC).isoformat(),
"message": _status_message(status),
}
def _check(
check_id: str,
label: str,
status: CheckStatus,
message: str | None = None,
*,
action_url: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"id": check_id, "label": label, "status": status}
if message:
payload["message"] = message
if action_url:
payload["action_url"] = action_url
return payload
def _assign(values: dict[str, Any], field: str, value: Any) -> None:
target = values
parts = field.split(".")
for part in parts[:-1]:
current = target.get(part)
if not isinstance(current, dict):
current = {}
target[part] = current
target = current
target[parts[-1]] = value
def _get(values: dict[str, Any], field: str) -> Any:
target: Any = values
for part in field.split("."):
if not isinstance(target, dict):
return None
target = target.get(part)
return target
def _str(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
return str(value).strip()
def _int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return _str(value).lower() in {"1", "true", "yes", "on", "granted"}
def _enabled(values: dict[str, Any]) -> bool:
return _truthy(values.get("enabled"))
def _label(field: str) -> str:
words = re.sub(r"([a-z])([A-Z])", r"\1 \2", field).replace(".", " ").replace("_", " ")
return words[:1].upper() + words[1:]
def _status_message(status: str) -> str:
return {
"connected": "Connection verified.",
"configured": "Configuration is present, but full verification was not possible.",
"needs_setup": "Required setup is missing.",
"invalid": "Configuration was checked and looks invalid.",
"unsupported": "This channel is not supported by the WebUI setup checker.",
}.get(status, "Channel checked.")
def _message_from_response(data: dict[str, Any], fallback: str) -> str:
error = data.get("error") or data.get("description") or data.get("message")
return str(error) if error else fallback
def _http_get(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]:
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
response = client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]:
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
response = client.post(url, headers=headers)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None:
url_host = host if ":" not in host or host.startswith("[") else f"[{host}]"
ok, error, resolved_ips = resolve_url_target(
f"http://{url_host}:{port}/",
allow_loopback=allow_loopback,
)
if not ok:
raise ValueError(error)
context = ssl.create_default_context()
last_error: OSError | None = None
for target_ip in resolved_ips:
try:
with socket.create_connection((target_ip, port), timeout=_TIMEOUT_SECONDS) as sock:
if port in {465, 993, 995}:
with context.wrap_socket(sock, server_hostname=host.strip("[]")):
return
return
except OSError as exc:
last_error = exc
if last_error is not None:
raise last_error
raise OSError(f"Could not resolve {host}")
+2
View File
@@ -47,6 +47,7 @@ def build_gateway_services(
local_trigger_store: Any | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
tokens = GatewayTokenStore()
@@ -77,6 +78,7 @@ def build_gateway_services(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
log=logger,
)
return GatewayServices(
+4 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any
from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID
from nanobot.optional_features import (
OptionalFeatureError,
disable_optional_feature,
@@ -25,10 +26,11 @@ def nanobot_features_action(
allow_install: bool = True,
) -> dict[str, Any]:
name = (query_first(query, "name") or "").strip()
instance_id = (query_first(query, "instance_id") or DEFAULT_INSTANCE_ID).strip()
if not name:
raise OptionalFeatureError("missing feature name")
if action == "enable":
return enable_optional_feature(name, allow_install=allow_install)
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
if action == "disable":
if name == "websocket":
raise OptionalFeatureError(
@@ -36,5 +38,5 @@ def nanobot_features_action(
"Use `nanobot plugins disable websocket` from a terminal if you need to disable it.",
status=400,
)
return disable_optional_feature(name)
return disable_optional_feature(name, instance_id=instance_id)
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
+144 -7
View File
@@ -29,6 +29,7 @@ from nanobot.providers.image_generation import (
image_gen_provider_names,
)
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
from nanobot.security.network import is_loopback_host
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.token_usage import token_usage_payload
from nanobot.webui.workspaces import (
@@ -46,6 +47,31 @@ def _version_payload() -> dict[str, Any]:
"current": __version__,
}
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
def _docs_version(version: str) -> str:
"""Map package versions to the matching public docs path."""
normalized = version.strip()
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
return normalized
return "latest"
def _docs_payload() -> dict[str, Any]:
"""Return version-aware documentation links for the WebUI."""
docs_version = _docs_version(__version__)
base_url = f"https://nanobot.wiki/docs/{docs_version}"
return {
"version": docs_version,
"base_url": base_url,
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
"latest_url": _DOCS_LATEST_URL,
}
_RUNTIME_CAPABILITIES = {
"can_restart_engine": False,
"can_pick_folder": False,
@@ -342,6 +368,7 @@ def _provider_settings_row(
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
"model_selectable": not spec.is_transcription_only,
"model_catalog": _model_catalog_kind(spec),
}
if oauth_status is not None:
row["oauth_account"] = oauth_status["account"]
@@ -352,6 +379,38 @@ def _provider_settings_row(
return row
def _provider_settings_rows(config: Any, selected_provider: str | None) -> list[dict[str, Any]]:
"""Return one Settings row per provider family while preserving legacy configs."""
aliases: dict[str, list[Any]] = {}
for spec in PROVIDERS:
if spec.settings_alias_for:
aliases.setdefault(spec.settings_alias_for, []).append(spec)
rows: list[dict[str, Any]] = []
for canonical in PROVIDERS:
if canonical.settings_alias_for:
continue
candidates = [canonical, *aliases.get(canonical.name, [])]
chosen = next((spec for spec in candidates if spec.name == selected_provider), None)
if chosen is None:
chosen = next(
(
spec
for spec in candidates
if (provider_config := getattr(config.providers, spec.name, None)) is not None
and _provider_configured_for_settings(spec, provider_config)
),
canonical,
)
provider_config = getattr(config.providers, chosen.name, None)
if provider_config is None:
continue
row = _provider_settings_row(chosen.name, chosen, provider_config)
row["label"] = canonical.label
rows.append(row)
return rows
def _model_catalog_kind(spec: Any) -> str:
catalog = getattr(spec, "model_catalog", "auto")
if catalog != "auto":
@@ -404,20 +463,27 @@ def _model_row_payload(row: Any) -> dict[str, Any] | None:
if not model_id:
return None
label: str | None = None
description: str | None = None
owned_by: str | None = None
if isinstance(row, dict):
raw_label = row.get("display_name") or row.get("label") or row.get("name")
if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id:
label = raw_label.strip()
raw_description = row.get("description")
if isinstance(raw_description, str) and raw_description.strip():
description = raw_description.strip()
raw_owner = row.get("owned_by") or row.get("owner") or row.get("organization")
if isinstance(raw_owner, str) and raw_owner.strip():
owned_by = raw_owner.strip()
return {
payload = {
"id": model_id,
"label": label,
"owned_by": owned_by,
"context_window": _model_context_window(row),
}
if description:
payload["description"] = description
return payload
def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
@@ -469,6 +535,24 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
"message": "Model list is not available for this provider. Type a model ID manually.",
}
if catalog_kind == "builtin":
rows = [
{
"id": model.id,
"label": model.label or None,
"description": model.description or None,
"owned_by": spec.label,
"context_window": model.context_window,
}
for model in spec.builtin_models
]
return {
**base_payload,
"status": "available",
"models": rows,
"model_count": len(rows),
}
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
if spec.name == "openai" and not api_base:
api_base = "https://api.openai.com/v1"
@@ -684,12 +768,7 @@ def settings_payload(
spec = find_by_name(effective_preset.provider)
selected_provider = spec.name if spec else provider_name
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None:
continue
providers.append(_provider_settings_row(spec.name, spec, provider_config))
providers = _provider_settings_rows(config, selected_provider)
for provider_key, provider_config in _dynamic_provider_items(config):
providers.append(
_provider_settings_row(
@@ -795,6 +874,20 @@ def settings_payload(
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
},
},
"api": {
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": _mask_secret_hint(config.api.api_key),
},
"observability": {
"provider": "langfuse",
"configured": bool(
os.environ.get("LANGFUSE_SECRET_KEY")
and os.environ.get("LANGFUSE_PUBLIC_KEY")
),
"base_url": os.environ.get("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com",
},
"image_generation": {
"enabled": image_config.enabled,
"provider": image_config.provider,
@@ -850,6 +943,7 @@ def settings_payload(
},
"requires_restart": requires_restart,
"version": _version_payload(),
"docs": _docs_payload(),
}
return decorate_settings_payload(
payload,
@@ -1317,6 +1411,49 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=restart_required)
def update_api_settings(query: QueryParams) -> dict[str, Any]:
"""Update the managed OpenAI-compatible API configuration."""
config = load_config()
api = config.api
host = _query_first(query, "host")
if host is not None:
host = host.strip()
if not host:
raise WebUISettingsError("host is required")
api.host = host
port = _query_first(query, "port")
if port is not None:
try:
parsed_port = int(port)
except ValueError:
raise WebUISettingsError("port must be an integer") from None
if parsed_port < 1 or parsed_port > 65535:
raise WebUISettingsError("port must be between 1 and 65535")
api.port = parsed_port
timeout = _query_first(query, "timeout")
if timeout is not None:
try:
parsed_timeout = float(timeout)
except ValueError:
raise WebUISettingsError("timeout must be a number") from None
if parsed_timeout < 1 or parsed_timeout > 3600:
raise WebUISettingsError("timeout must be between 1 and 3600")
api.timeout = parsed_timeout
api_key = _query_first_alias(query, "api_key", "apiKey")
if api_key is not None:
api.api_key = api_key.strip()
if not is_loopback_host(api.host) and not api.api_key.strip():
raise WebUISettingsError("an API key is required when the API is available on the network")
save_config(config)
return settings_payload()
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
image_config = config.tools.image_generation
+696 -2
View File
@@ -8,7 +8,9 @@ request mapping and response shaping.
from __future__ import annotations
import asyncio
import inspect
import json
import time
from collections.abc import Callable
from typing import Any
@@ -16,9 +18,22 @@ from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config
from nanobot.optional_features import OptionalFeatureError
from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.optional_features import (
OptionalFeatureError,
extra_installed,
optional_dependency_groups,
)
from nanobot.pairing import approve_code, deny_code, list_pending
from nanobot.webui.channel_connect import (
ChannelConnectError,
FeishuConnectStore,
WeixinConnectStore,
)
from nanobot.webui.channel_validation import validate_channel_config
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
from nanobot.webui.http_utils import query_first as _query_first
@@ -34,6 +49,7 @@ from nanobot.webui.settings_api import (
settings_payload,
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
@@ -47,6 +63,12 @@ QueryParams = dict[str, list[str]]
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
_SKIP_FIELD = object()
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
@@ -73,6 +95,7 @@ class WebUISettingsRouter:
error_response: Callable[[int, str | None], Response],
runtime_surface: str,
runtime_capabilities: dict[str, Any],
channel_feature_action: Callable[..., Any] | None = None,
) -> None:
self.bus = bus
self.logger = logger
@@ -82,7 +105,10 @@ class WebUISettingsRouter:
self._error_response = error_response
self._runtime_surface = runtime_surface
self._runtime_capabilities = runtime_capabilities
self._channel_feature_action = channel_feature_action
self._restart_sections: set[str] = set()
self._feishu_connect = FeishuConnectStore()
self._weixin_connect = WeixinConnectStore()
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings":
@@ -105,6 +131,12 @@ class WebUISettingsRouter:
return await self._handle_settings_provider_oauth(request, "logout")
if path == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if path == "/api/settings/api-service":
return self._handle_settings_api_service(request)
if path == "/api/settings/api-service/start":
return await self._handle_settings_api_service_start(connection, request)
if path == "/api/settings/api-service/stop":
return await self._handle_settings_api_service_stop(request)
if path == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if path == "/api/settings/transcription/update":
@@ -127,6 +159,28 @@ class WebUISettingsRouter:
return await self._handle_settings_nanobot_features_action(connection, request, "enable")
if path == "/api/settings/nanobot-features/disable":
return await self._handle_settings_nanobot_features_action(connection, request, "disable")
if path == "/api/settings/channels/feishu/connect/start":
return await self._handle_settings_feishu_connect_start(request)
if path == "/api/settings/channels/feishu/connect/poll":
return await self._handle_settings_feishu_connect_poll(connection, request)
if path == "/api/settings/channels/feishu/connect/cancel":
return self._handle_settings_feishu_connect_cancel(request)
if path == "/api/settings/channels/weixin/connect/start":
return await self._handle_settings_weixin_connect_start(connection, request)
if path == "/api/settings/channels/weixin/connect/poll":
return await self._handle_settings_weixin_connect_poll(connection, request)
if path == "/api/settings/channels/weixin/connect/cancel":
return await self._handle_settings_weixin_connect_cancel(request)
if path == "/api/settings/channels/validate":
return await self._handle_settings_channel_validate(request)
if path == "/api/settings/channels/configure":
return await self._handle_settings_channel_configure(connection, request)
if path == "/api/settings/pairing":
return self._handle_settings_pairing(request)
if path == "/api/settings/pairing/approve":
return self._handle_settings_pairing_action(request, "approve")
if path == "/api/settings/pairing/deny":
return self._handle_settings_pairing_action(request, "deny")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
if path == "/api/settings/version-check":
@@ -209,6 +263,46 @@ class WebUISettingsRouter:
return self._unauthorized()
return self._json_response(settings_usage_payload())
def _handle_settings_pairing(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(_pairing_payload())
def _handle_settings_pairing_action(self, request: WsRequest, action: str) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
code = (_query_first(query, "code") or "").strip()
if not code:
return self._error_response(400, "Missing pairing code")
if action == "approve":
result = approve_code(code)
if result is None:
return self._error_response(404, "Pairing code not found or expired")
channel, sender_id = result
return self._json_response(
_pairing_payload({
"ok": True,
"action": "approve",
"message": f"Approved {sender_id} for {channel}",
"channel": channel,
"sender_id": sender_id,
"code": code,
})
)
if not deny_code(code):
return self._error_response(404, "Pairing code not found or expired")
return self._json_response(
_pairing_payload({
"ok": True,
"action": "deny",
"message": f"Denied pairing code {code}",
"code": code,
})
)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
@@ -283,6 +377,134 @@ class WebUISettingsRouter:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="browser"))
def _handle_settings_api_service(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(self._api_service_payload())
async def _handle_settings_api_service_start(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
await asyncio.to_thread(
nanobot_features_action,
"enable",
{"name": ["api"]},
allow_install=self._allow_feature_package_install(connection, request),
)
update_api_settings(self._parse_api_service_settings_query(request))
config = load_config()
runtime = self._api_runtime()
options = ApiStartOptions(
host=config.api.host,
port=config.api.port,
workspace=str(config.workspace_path),
config_path=str(get_config_path().expanduser().resolve(strict=False)),
)
current = runtime.status()
result = await asyncio.to_thread(
runtime.restart if current.running else runtime.start_background,
options,
)
if not result.ok:
return self._error_response(500, self._api_runtime_message(result.message))
except (WebUISettingsError, OptionalFeatureError) as e:
return self._error_response(getattr(e, "status", 400), getattr(e, "message", str(e)))
except Exception as e:
self.logger.exception("failed to start managed API service")
return self._error_response(500, str(e))
return self._json_response(self._api_service_payload(last_action="started"))
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request)
if "api_key" in query or "apiKey" in query:
raise WebUISettingsError("API service API key must be provided in the private header")
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("API service settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid API service settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("API service settings payload must be a JSON object")
unknown = set(payload) - {"api_key"}
if unknown:
raise WebUISettingsError("API service settings payload contains an invalid key")
api_key = payload.get("api_key")
if api_key is not None and not isinstance(api_key, str):
raise WebUISettingsError("API service API key must be a string")
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
if api_key is not None:
merged["api_key"] = [api_key]
return merged
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
result = await asyncio.to_thread(self._api_runtime().stop)
except Exception as e:
self.logger.exception("failed to stop managed API service")
return self._error_response(500, str(e))
if not result.ok and result.message != "api_not_running":
return self._error_response(500, self._api_runtime_message(result.message))
return self._json_response(self._api_service_payload(last_action="stopped"))
@staticmethod
def _api_runtime() -> ApiRuntime:
config_path = get_config_path().expanduser().resolve(strict=False)
return ApiRuntime(paths=api_runtime_paths(config_path))
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
config = load_config()
status = self._api_runtime().status()
extras = optional_dependency_groups()
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
payload = {
"installed": extra_installed("api", extras.get("api")),
"running": status.running,
"managed": status.running,
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": self._masked_secret(config.api.api_key),
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
"command": "nanobot serve",
"log_path": str(status.log_path),
}
if last_action:
payload["last_action"] = last_action
return payload
@staticmethod
def _masked_secret(value: str) -> str | None:
value = value.strip()
if not value:
return None
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
@staticmethod
def _api_runtime_message(message: str) -> str:
known = {
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
"api_stop_timeout": "API server did not stop in time.",
"api_state_stale": "API server state was stale; try starting it again.",
}
if message in known:
return known[message]
if message.startswith("api_"):
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
return message.replace("_", " ")
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
@@ -378,8 +600,460 @@ class WebUISettingsRouter:
if status >= 500:
self.logger.exception("nanobot feature action '{}' failed", action)
return self._error_response(status, message)
payload = await self._apply_nanobot_feature_runtime_change(
action,
self._query(request),
payload,
)
return self._json_response(self._with_restart_state(payload, section="runtime"))
async def _apply_nanobot_feature_runtime_change(
self,
action: str,
query: QueryParams,
payload: dict[str, Any],
) -> dict[str, Any]:
if self._channel_feature_action is None:
return payload
name = (_query_first(query, "name") or "").strip()
if not name:
return payload
try:
instance_id = (_query_first(query, "instance_id") or "").strip()
runtime_name = name
if name == "feishu" and instance_id and instance_id != "default":
runtime_name = f"feishu.{instance_id}"
result = self._channel_feature_action(action, runtime_name)
if inspect.isawaitable(result):
result = await result
except Exception as exc:
self.logger.exception("failed to apply channel '{}' without restart", name)
return self._feature_runtime_fallback(
payload,
message=f"{name} channel config was saved, but hot reload failed: {exc}",
)
if not isinstance(result, dict) or not result.get("handled"):
return payload
payload = dict(payload)
if result.get("requires_restart"):
payload["requires_restart"] = True
else:
payload["requires_restart"] = False
message = result.get("message")
if isinstance(message, str) and message:
last_action = dict(payload.get("last_action") or {})
previous = last_action.get("message")
if isinstance(previous, str) and previous:
last_action["message"] = f"{previous}. {message}"
else:
last_action["message"] = message
last_action["hot_reload"] = not payload["requires_restart"]
payload["last_action"] = last_action
return payload
@staticmethod
def _feature_runtime_fallback(payload: dict[str, Any], *, message: str) -> dict[str, Any]:
payload = dict(payload)
payload["requires_restart"] = True
last_action = dict(payload.get("last_action") or {})
previous = last_action.get("message")
last_action["message"] = f"{previous}. {message}" if isinstance(previous, str) and previous else message
last_action["hot_reload"] = False
payload["last_action"] = last_action
return payload
async def _handle_settings_channel_configure(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
name = (_query_first(query, "name") or "").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
enable = (_query_first(query, "enable") or "").strip().lower() in {"1", "true", "yes"}
try:
saved = await asyncio.to_thread(
self._save_channel_config_values,
name,
self._parse_channel_values_header(request),
instance_id,
)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to save channel '{}' settings", name)
return self._error_response(500, "failed to save channel settings")
payload: dict[str, Any] = {
"name": name,
"saved": True,
"saved_keys": saved,
}
if not enable:
return self._json_response(payload)
feature_query = {"name": [name]}
if name == "feishu":
feature_query["instance_id"] = [instance_id]
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
feature_query,
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as e:
return self._error_response(e.status, f"Settings saved, but {e.message}")
except Exception as e:
self.logger.exception("failed to enable channel '{}' after settings save", name)
return self._error_response(500, f"Settings saved, but enabling {name} failed: {e}")
features = await self._apply_nanobot_feature_runtime_change(
"enable",
feature_query,
features,
)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return self._json_response(payload)
async def _handle_settings_channel_validate(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
name = (_query_first(query, "name") or "").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
try:
payload = await asyncio.to_thread(
validate_channel_config,
name,
self._parse_channel_values_header(request),
instance_id=instance_id,
)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to validate channel '{}' settings", name)
return self._error_response(500, "failed to validate channel settings")
return self._json_response(payload)
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
if not raw:
return {}
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("channel settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid channel settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("channel settings payload must be a JSON object")
return payload
def _save_channel_config_values(
self,
name: str,
raw_values: dict[str, Any],
instance_id: str = "default",
) -> list[str]:
if not name:
raise WebUISettingsError("missing channel name")
setup_spec = channel_setup_spec(name)
if setup_spec is None:
raise WebUISettingsError(f"channel '{name}' cannot be configured from WebUI", status=404)
field_types = setup_spec.route_field_types
if not raw_values:
return []
config = load_config()
section = getattr(config.channels, name, None)
if name == "feishu":
from nanobot.channels._feishu_instances import feishu_instance_specs
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(section, FeishuChannel.default_config())
selected = next((spec for spec in specs if spec.instance_id == instance_id), None)
channel_config = dict(selected.config) if selected is not None else {}
elif hasattr(section, "model_dump"):
channel_config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
channel_config = dict(section)
else:
channel_config = {}
saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key:
raise WebUISettingsError("channel settings payload contains an invalid key")
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = self._coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
self._assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
if name == "feishu":
from nanobot.channels._feishu_instances import upsert_feishu_instance
from nanobot.channels.feishu import FeishuChannel
existing = getattr(config.channels, name, None)
channel_config = upsert_feishu_instance(
existing if isinstance(existing, dict) else {},
FeishuChannel.default_config(),
instance_id,
channel_config,
)
setattr(config.channels, name, channel_config)
save_config(config)
return saved
@staticmethod
def _coerce_channel_value(raw_key: str, raw_value: Any, value_type: Any) -> Any:
if isinstance(value_type, tuple):
kind = value_type[0]
allowed = value_type[1]
else:
kind = value_type
allowed = None
if kind in {"string", "secret"}:
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if kind == "secret" and not value:
return _SKIP_FIELD
return value
if kind == "list":
if raw_value is None:
return []
if isinstance(raw_value, str):
return [item.strip() for item in raw_value.split(",") if item.strip()]
if isinstance(raw_value, list):
return [str(item).strip() for item in raw_value if str(item).strip()]
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
if kind == "int":
if raw_value in (None, ""):
return _SKIP_FIELD
try:
return int(raw_value)
except (TypeError, ValueError) as exc:
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
if kind == "bool":
if isinstance(raw_value, bool):
return raw_value
value = str(raw_value).strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
if value in {"false", "0", "no", "off"}:
return False
raise WebUISettingsError(f"'{raw_key}' must be true or false")
if kind == "enum":
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if not value:
return _SKIP_FIELD
if value not in allowed:
options = ", ".join(sorted(allowed))
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
return value
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
@staticmethod
def _assign_channel_config_value(channel_config: dict[str, Any], field: str, value: Any) -> None:
target = channel_config
parts = field.split(".")
for part in parts[:-1]:
current = target.get(part)
if not isinstance(current, dict):
current = {}
target[part] = current
target = current
target[parts[-1]] = value
async def _handle_settings_feishu_connect_start(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
domain = (_query_first(query, "domain") or "feishu").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
mode = (_query_first(query, "mode") or "replace").strip()
try:
payload = await asyncio.to_thread(
self._feishu_connect.start,
domain=domain,
instance_id=instance_id,
mode=mode,
)
except ChannelConnectError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to start Feishu WebUI connect")
return self._error_response(500, "failed to start Feishu connection")
return self._json_response(payload)
async def _handle_settings_feishu_connect_poll(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing Feishu connect session")
try:
payload = await asyncio.to_thread(self._feishu_connect.poll, session_id)
except Exception:
self.logger.exception("failed to poll Feishu WebUI connect")
return self._error_response(500, "failed to poll Feishu connection")
if payload.get("status") == "succeeded":
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
{
"name": ["feishu"],
"instance_id": [str(payload.get("instance_id") or "default")],
},
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as exc:
features = self._feature_runtime_fallback(
nanobot_features_payload(),
message=f"Feishu connected, but enabling channel support failed: {exc.message}",
)
else:
features = await self._apply_nanobot_feature_runtime_change(
"enable",
{
"name": ["feishu"],
"instance_id": [str(payload.get("instance_id") or "default")],
},
features,
)
payload = dict(payload)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return self._json_response(payload)
def _handle_settings_feishu_connect_cancel(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing Feishu connect session")
return self._json_response(self._feishu_connect.cancel(session_id))
async def _handle_settings_weixin_connect_start(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
force = (_query_first(self._query(request), "force") or "").strip().lower() in {
"1",
"true",
"yes",
}
try:
payload = await self._weixin_connect.start(force=force)
except ChannelConnectError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to start WeChat WebUI connect")
return self._error_response(500, "failed to start WeChat connection")
if payload.get("status") == "succeeded":
payload = await self._with_channel_connect_success(
connection,
request,
"weixin",
payload,
)
return self._json_response(payload)
async def _handle_settings_weixin_connect_poll(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing WeChat connect session")
try:
payload = await self._weixin_connect.poll(session_id)
except Exception:
self.logger.exception("failed to poll WeChat WebUI connect")
return self._error_response(500, "failed to poll WeChat connection")
if payload.get("status") == "succeeded":
payload = await self._with_channel_connect_success(
connection,
request,
"weixin",
payload,
)
return self._json_response(payload)
async def _handle_settings_weixin_connect_cancel(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing WeChat connect session")
return self._json_response(await self._weixin_connect.cancel(session_id))
async def _with_channel_connect_success(
self,
connection: Any,
request: WsRequest,
channel_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
{"name": [channel_name]},
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as exc:
features = self._feature_runtime_fallback(
nanobot_features_payload(),
message=(
f"{channel_name} connected, but enabling channel support failed: "
f"{exc.message}"
),
)
else:
features = await self._apply_nanobot_feature_runtime_change(
"enable",
{"name": [channel_name]},
features,
)
payload = dict(payload)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return payload
def _allow_feature_package_install(self, connection: Any, request: WsRequest) -> bool:
if _is_local_browser_request(connection, request.headers):
return True
@@ -423,3 +1097,23 @@ class WebUISettingsRouter:
return self._json_response({
"updateAvailable": update_info,
})
def _pairing_payload(last_action: dict[str, Any] | None = None) -> dict[str, Any]:
now = time.time()
requests = []
for item in list_pending():
expires_at = float(item.get("expires_at", 0) or 0)
created_at = float(item.get("created_at", 0) or 0)
requests.append({
"code": str(item.get("code", "")),
"channel": str(item.get("channel", "")),
"sender_id": str(item.get("sender_id", "")),
"created_at_ms": int(created_at * 1000) if created_at else None,
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
"expires_in_seconds": max(0, int(expires_at - now)) if expires_at else None,
})
payload: dict[str, Any] = {"requests": requests}
if last_action is not None:
payload["last_action"] = last_action
return payload
+2
View File
@@ -162,6 +162,7 @@ class GatewayHTTPHandler:
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
log: Any = logger,
) -> None:
self.config = config
@@ -194,6 +195,7 @@ class GatewayHTTPHandler:
error_response=_http_error,
runtime_surface=runtime_surface,
runtime_capabilities=self._capabilities,
channel_feature_action=channel_feature_action,
)
def workspace_controls_available(self, connection: Any) -> bool:
+12 -1
View File
@@ -49,6 +49,11 @@ dependencies = [
"pyyaml>=6.0,<7.0.0",
"filelock>=3.25.2",
"packaging>=24.0",
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
]
[project.optional-dependencies]
@@ -65,6 +70,8 @@ dingtalk = [
"dingtalk-stream>=0.24.0,<1.0.0",
]
documents = [
# Compatibility extra: document readers are bundled since v0.2.3.
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
@@ -123,8 +130,12 @@ whatsapp = [
langsmith = [
"langsmith>=0.1.0",
]
langfuse = [
"langfuse>=3.0.0,<4.0.0",
]
pdf = [
"pymupdf>=1.25.0",
# Compatibility extra: PDF reading is bundled since v0.2.3.
"pypdf>=5.0.0,<6.0.0",
]
olostep = [
"olostep>=0.1.0",
+15 -15
View File
@@ -11,7 +11,6 @@ from typing import Any, cast
from pydantic import BaseModel, Field
from nanobot.cli import onboard as onboard_wizard
from nanobot.cli.commands import _merge_missing_defaults
from nanobot.cli.onboard import (
_BACK_PRESSED,
_configure_pydantic_model,
@@ -22,18 +21,19 @@ from nanobot.cli.onboard import (
_input_text,
run_onboard,
)
from nanobot.config.loader import merge_missing_defaults
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.utils.helpers import sync_workspace_templates
class TestMergeMissingDefaults:
"""Tests for _merge_missing_defaults recursive config merging."""
"""Tests for recursive config default merging."""
def test_adds_missing_top_level_keys(self):
existing = {"a": 1}
defaults = {"a": 1, "b": 2, "c": 3}
result = _merge_missing_defaults(existing, defaults)
result = merge_missing_defaults(existing, defaults)
assert result == {"a": 1, "b": 2, "c": 3}
@@ -41,7 +41,7 @@ class TestMergeMissingDefaults:
existing = {"a": "custom_value"}
defaults = {"a": "default_value"}
result = _merge_missing_defaults(existing, defaults)
result = merge_missing_defaults(existing, defaults)
assert result == {"a": "custom_value"}
@@ -63,7 +63,7 @@ class TestMergeMissingDefaults:
}
}
result = _merge_missing_defaults(existing, defaults)
result = merge_missing_defaults(existing, defaults)
assert result == {
"level1": {
@@ -76,19 +76,19 @@ class TestMergeMissingDefaults:
}
def test_returns_existing_if_not_dict(self):
assert _merge_missing_defaults("string", {"a": 1}) == "string"
assert _merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
assert _merge_missing_defaults(None, {"a": 1}) is None
assert _merge_missing_defaults(42, {"a": 1}) == 42
assert merge_missing_defaults("string", {"a": 1}) == "string"
assert merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
assert merge_missing_defaults(None, {"a": 1}) is None
assert merge_missing_defaults(42, {"a": 1}) == 42
def test_returns_existing_if_defaults_not_dict(self):
assert _merge_missing_defaults({"a": 1}, "string") == {"a": 1}
assert _merge_missing_defaults({"a": 1}, None) == {"a": 1}
assert merge_missing_defaults({"a": 1}, "string") == {"a": 1}
assert merge_missing_defaults({"a": 1}, None) == {"a": 1}
def test_handles_empty_dicts(self):
assert _merge_missing_defaults({}, {"a": 1}) == {"a": 1}
assert _merge_missing_defaults({"a": 1}, {}) == {"a": 1}
assert _merge_missing_defaults({}, {}) == {}
assert merge_missing_defaults({}, {"a": 1}) == {"a": 1}
assert merge_missing_defaults({"a": 1}, {}) == {"a": 1}
assert merge_missing_defaults({}, {}) == {}
def test_backfills_channel_config(self):
"""Real-world scenario: backfill missing channel fields."""
@@ -105,7 +105,7 @@ class TestMergeMissingDefaults:
"allowFrom": [],
}
result = _merge_missing_defaults(existing_channel, default_channel)
result = merge_missing_defaults(existing_channel, default_channel)
assert result["msgFormat"] == "plain"
assert result["allowFrom"] == []
@@ -0,0 +1,114 @@
from __future__ import annotations
import asyncio
import pytest
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
from nanobot.config.schema import Config
class _HotChannel(BaseChannel):
name = "hot"
display_name = "Hot"
def __init__(self, config, bus):
super().__init__(config, bus)
self.started = asyncio.Event()
self.stopped = asyncio.Event()
async def start(self):
self._running = True
self.started.set()
await self.stopped.wait()
async def stop(self):
self._running = False
self.stopped.set()
async def send(self, msg): # pragma: no cover - not used by this test
raise AssertionError("send should not be called")
@pytest.mark.asyncio
async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch):
disabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": False},
}
})
enabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": True},
}
})
import nanobot.channels.registry as registry
def discover_enabled(enabled_names, **_kwargs):
return {"hot": _HotChannel} if "hot" in enabled_names else {}
configs = iter([enabled, disabled])
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
manager = ChannelManager(disabled, MessageBus())
manager._started = True
enabled_result = await manager.apply_channel_feature_action("enable", "hot")
assert enabled_result["handled"] is True
assert enabled_result["requires_restart"] is False
channel = manager.channels["hot"]
await asyncio.wait_for(channel.started.wait(), timeout=1)
assert channel.is_running is True
disabled_result = await manager.apply_channel_feature_action("disable", "hot")
assert disabled_result["handled"] is True
assert disabled_result["requires_restart"] is False
assert "hot" not in manager.channels
assert channel.is_running is False
@pytest.mark.asyncio
async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_fails(monkeypatch):
enabled = Config.model_validate({
"channels": {
"websocket": {"enabled": False},
"hot": {"enabled": True},
}
})
import nanobot.channels.registry as registry
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
monkeypatch.setattr(
registry,
"discover_enabled",
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
manager = ChannelManager(enabled, MessageBus())
old_channel = manager.channels["hot"]
old_channel._running = True
def fail_build(*_args, **_kwargs):
raise RuntimeError("invalid replacement config")
monkeypatch.setattr(manager, "_build_channel", fail_build)
result = await manager.apply_channel_feature_action("enable", "hot")
assert result["requires_restart"] is True
assert manager.channels["hot"] is old_channel
assert old_channel.is_running is True
assert not old_channel.stopped.is_set()
+486 -4
View File
@@ -72,6 +72,34 @@ class _FakeTelegram(BaseChannel):
pass
class _FakeFeishu(BaseChannel):
name = "feishu"
display_name = "Feishu"
@classmethod
def default_config(cls) -> dict:
return {
"instanceId": "default",
"name": "nanobot",
"enabled": False,
"appId": "",
"appSecret": "",
"domain": "feishu",
"groupPolicy": "mention",
"topicIsolation": True,
"allowFrom": [],
}
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def send(self, msg: OutboundMessage) -> None:
pass
def _make_entry_point(name: str, cls: type):
"""Create a mock entry point that returns *cls* on load()."""
ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
@@ -136,6 +164,49 @@ def test_channels_config_extract_document_text_accepts_camel_alias():
assert cfg.extract_document_text is False
def test_channel_manager_expands_feishu_instances(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr(
"nanobot.channels.registry.discover_enabled",
lambda enabled, _names=None, warn_import_errors=True: {"feishu": _FakeFeishu}
if "feishu" in enabled
else {},
)
cfg = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"enabled": True,
"appId": "cli_product",
"appSecret": "secret",
},
{
"id": "off",
"enabled": False,
"appId": "cli_off",
"appSecret": "secret",
},
]
}
}
})
manager = ChannelManager(cfg, MessageBus())
assert set(manager.channels) == {"feishu", "feishu.product"}
assert manager.channels["feishu"].name == "feishu"
assert manager.channels["feishu.product"].name == "feishu.product"
# ---------------------------------------------------------------------------
# discover_plugins
# ---------------------------------------------------------------------------
@@ -201,6 +272,17 @@ def test_discover_all_includes_builtins():
assert name in discover_channel_names()
def test_discover_channel_names_excludes_internal_helpers():
from nanobot.channels.registry import discover_channel_names
names = discover_channel_names()
assert "_feishu_ws" not in names
assert "_setup" not in names
assert "setup" not in names
assert "_feishu_instances" not in names
def test_discover_all_includes_external_plugin():
from nanobot.channels.registry import discover_all
@@ -891,6 +973,25 @@ def test_enable_optional_feature_skips_install_when_dependency_present(
assert not config_path.exists()
def test_enable_optional_feature_lazy_reader_does_not_require_restart(monkeypatch, tmp_path):
from nanobot.optional_features import enable_optional_feature
config_path = tmp_path / "config.json"
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: [])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr(
"nanobot.optional_features.optional_dependency_groups",
lambda: {"documents": ["pypdf>=5.0.0,<6.0.0"]},
)
monkeypatch.setattr("nanobot.optional_features.extra_installed", lambda _name, _deps: True)
payload = enable_optional_feature("documents", config_path=config_path)
assert payload["requires_restart"] is False
assert payload["last_action"]["message"] == "Feature 'documents' is included with nanobot"
def test_enable_optional_feature_reports_install_failure(monkeypatch, tmp_path):
from nanobot.optional_features import (
InstallResult,
@@ -1009,6 +1110,370 @@ def test_optional_features_payload_counts_enabled_channel_with_missing_dependenc
assert payload["enabled_count"] == 1
def test_optional_features_payload_reflects_saved_channel_config(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"discord": {
"enabled": False,
"token": "discord-secret-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["name"] == "discord"
assert discord["enabled"] is False
assert discord["configured"] is True
assert discord["config_values"] == {
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
assert discord["configured_fields"] == [
"channels.discord.token",
"channels.discord.allowChannels",
"channels.discord.groupPolicy",
]
assert "discord-secret-token" not in json.dumps(payload)
def test_optional_features_payload_marks_enabled_channel_missing_credentials(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({"channels": {"discord": {"enabled": True}}})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["discord"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
discord = payload["features"][0]
assert discord["enabled"] is True
assert discord["configured"] is False
assert "config_values" not in discord
assert "configured_fields" not in discord
def test_optional_features_payload_detects_saved_weixin_login_state(tmp_path, monkeypatch):
from nanobot.optional_features import optional_features_payload
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "saved-weixin-token"}),
encoding="utf-8",
)
config = Config.model_validate({
"channels": {
"weixin": {
"enabled": True,
"stateDir": str(state_dir),
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
weixin = payload["features"][0]
assert weixin["enabled"] is True
assert weixin["configured"] is True
def test_optional_features_payload_detects_legacy_default_weixin_state(tmp_path, monkeypatch):
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
state_dir = tmp_path / "weixin"
state_dir.mkdir()
(state_dir / "account.json").write_text(
json.dumps({"token": "legacy-weixin-token"}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=Config())
weixin = payload["features"][0]
assert weixin["enabled"] is False
assert weixin["configured"] is True
@pytest.mark.parametrize("device_id", ["", "DEVICE-ID"])
def test_optional_features_payload_requires_matrix_device_id_for_token_login(
monkeypatch,
device_id,
):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"matrix": {
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
"deviceId": device_id,
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["matrix"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
assert payload["features"][0]["configured"] is bool(device_id)
def test_optional_features_payload_marks_disabled_feishu_as_configured(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"enabled": False,
"appId": "cli_test",
"appSecret": "secret",
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is False
assert feishu["configured"] is True
assert feishu["ready"] is False
assert payload["enabled_count"] == 0
def test_optional_features_payload_lists_feishu_instances(monkeypatch):
from nanobot.optional_features import optional_features_payload
config = Config.model_validate({
"channels": {
"feishu": {
"instances": [
{
"id": "default",
"name": "nanobot",
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/bot.png",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
},
{
"id": "product",
"name": "Product bot",
"enabled": False,
"appId": "cli_product",
"appSecret": "secret",
},
]
}
}
})
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
payload = optional_features_payload(config=config)
feishu = payload["features"][0]
assert feishu["name"] == "feishu"
assert feishu["enabled"] is True
assert feishu["configured"] is True
assert payload["enabled_count"] == 1
assert feishu["instances"] == [
{
"id": "default",
"name": "nanobot",
"display_name": "Voraflare Bot",
"avatar_url": "https://example.com/bot.png",
"domain": "feishu",
"enabled": True,
"configured": True,
"app_id": "cli_default",
"group_policy": "mention",
"allow_from": [],
},
{
"id": "product",
"name": "Product bot",
"display_name": "Product bot",
"avatar_url": "",
"domain": "feishu",
"enabled": False,
"configured": True,
"app_id": "cli_product",
"group_policy": "mention",
"allow_from": [],
},
]
def test_optional_features_payload_backfills_saved_feishu_identity(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Xubin Ren的智能助手",
"avatarUrl": "https://example.com/assistant.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "Xubin Ren的智能助手"
assert instance["avatar_url"] == "https://example.com/assistant.png"
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["displayName"] == "Xubin Ren的智能助手"
assert saved["avatarUrl"] == "https://example.com/assistant.png"
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_optional_features_payload_records_feishu_identity_attempt_on_empty_result(
monkeypatch,
tmp_path,
):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "secret",
}]
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *args: {})
monkeypatch.setattr(feishu_module, "_identity_timestamp", lambda: "2026-07-06T00:00:00Z")
payload = optional_features_payload()
instance = payload["features"][0]["instances"][0]
assert instance["display_name"] == "nanobot"
assert instance["avatar_url"] == ""
data = json.loads(config_path.read_text(encoding="utf-8"))
saved = data["channels"]["feishu"]["instances"][0]
assert saved["identityFetchedAt"] == "2026-07-06T00:00:00Z"
assert "displayName" not in saved
assert "avatarUrl" not in saved
def test_optional_features_payload_preserves_legacy_flat_feishu_config(monkeypatch, tmp_path):
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.optional_features import optional_features_payload
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({
"channels": {
"feishu": {
"enabled": True,
"appId": "cli_legacy",
"appSecret": "legacy-secret",
"groupPolicy": "mention",
}
}
}),
config_path,
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["feishu"])
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
monkeypatch.setattr("nanobot.optional_features.optional_dependency_groups", lambda: {})
monkeypatch.setattr(feishu_module, "FEISHU_AVAILABLE", True)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda *_args: {
"displayName": "Legacy assistant",
"avatarUrl": "https://example.com/legacy.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
payload = optional_features_payload()
assert payload["features"][0]["instances"][0]["display_name"] == "Legacy assistant"
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_legacy"
assert saved["appSecret"] == "legacy-secret"
assert saved["displayName"] == "Legacy assistant"
assert saved["avatarUrl"] == "https://example.com/legacy.png"
assert "instances" not in saved
def test_enable_bootstraps_pip_with_ensurepip(monkeypatch):
from nanobot import optional_features
@@ -1066,6 +1531,8 @@ def test_run_install_command_returns_failure_on_timeout(monkeypatch):
def test_optional_dependency_metadata_for_enable():
from nanobot import optional_features
data = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["optional-dependencies"]
required = data["project"]["dependencies"]
@@ -1077,25 +1544,32 @@ def test_optional_dependency_metadata_for_enable():
"dingtalk-stream",
"lark-oapi",
"msgpack",
"openpyxl",
"pypdf",
"python-telegram-bot",
"python-docx",
"python-pptx",
"python-socketio",
"qq-botpy",
"slack-sdk",
"slackify-markdown",
):
assert not any(dep.startswith(dep_name) for dep in required)
for dependency in (
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
):
assert dependency in required
assert deps["dingtalk"] == ["dingtalk-stream>=0.24.0,<1.0.0"]
assert deps["documents"] == [
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
]
assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"]
assert deps["feishu"] == ["lark-oapi>=1.5.0,<2.0.0"]
assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"]
assert deps["mochat"] == [
"python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.1.0,<2.0.0",
@@ -1107,6 +1581,10 @@ def test_optional_dependency_metadata_for_enable():
"slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0,<1.0.0",
]
visible = optional_features.optional_dependency_groups()
assert "documents" not in visible
assert "pdf" not in visible
assert any(dep.startswith("python-telegram-bot") for dep in deps["telegram"])
assert any(
dep.startswith("matrix-nio>=0.25.2") and "sys_platform == 'win32'" in dep
@@ -1986,6 +2464,7 @@ async def test_stop_all_cancels_dispatcher_and_stops_channels():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
# Create a real cancelled task
async def dummy_task():
@@ -2063,6 +2542,7 @@ async def test_stop_all_handles_channel_exception():
mgr.config = fake_config
mgr.bus = MessageBus()
mgr.channels = {"stopfailing": _StopFailingChannel(fake_config, mgr.bus)}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Should not raise even if channel.stop() raises
@@ -2101,6 +2581,7 @@ async def test_stop_all_handles_channel_stop_cancelled_task():
"stopcancelled": _StopCancelledChannel(fake_config, mgr.bus),
"next": next_channel,
}
mgr._channel_tasks = {}
mgr._dispatch_task = None
await mgr.stop_all()
@@ -2142,6 +2623,7 @@ async def test_start_all_creates_dispatch_task():
ch = _StartableChannel(fake_config, mgr.bus)
mgr.channels = {"startable": ch}
mgr._channel_tasks = {}
mgr._dispatch_task = None
# Cancel immediately after start to avoid running forever
+54
View File
@@ -0,0 +1,54 @@
from nanobot.channels._setup import channel_setup_spec
def test_channel_setup_spec_derives_route_and_secret_metadata() -> None:
slack = channel_setup_spec("slack")
assert slack is not None
assert slack.secrets == {"appToken", "botToken"}
assert slack.route_field_types == {
"appToken": "secret",
"botToken": "secret",
"groupPolicy": ("enum", {"mention", "open", "allowlist"}),
}
assert slack.simple_required_fields == ("appToken", "botToken")
def test_matrix_setup_requires_one_complete_login_method() -> None:
matrix = channel_setup_spec("matrix")
assert matrix is not None
base = {
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
}
assert matrix.is_configured(base | {"password": "secret"})
assert matrix.is_configured(base | {"accessToken": "token", "deviceId": "DEVICE"})
assert not matrix.is_configured(base | {"accessToken": "token"})
def test_channel_setup_spec_separates_writable_and_snapshot_fields() -> None:
matrix = channel_setup_spec("matrix")
discord = channel_setup_spec("discord")
assert matrix is not None
assert discord is not None
assert "allowFrom" not in matrix.route_field_types
assert "allowFrom" in matrix.snapshot_fields
assert "allowFrom" in discord.route_field_types
assert "allowFrom" not in discord.snapshot_fields
def test_webui_forms_have_writable_mattermost_and_whatsapp_contracts() -> None:
mattermost = channel_setup_spec("mattermost")
whatsapp = channel_setup_spec("whatsapp")
assert mattermost is not None
assert whatsapp is not None
assert mattermost.route_field_types["serverUrl"] == "string"
assert mattermost.route_field_types["token"] == "secret"
assert whatsapp.route_field_types["allowFrom"] == "list"
assert whatsapp.route_field_types["groupPolicy"] == (
"enum",
{"mention", "open"},
)
+319 -5
View File
@@ -7,6 +7,11 @@ from nanobot.channels import feishu as feishu_module
from nanobot.channels.feishu import FeishuChannel
from nanobot.config import loader
from nanobot.config.schema import Config
from nanobot.pairing import store as pairing_store
def _default_feishu_instance(data: dict) -> dict:
return data["channels"]["feishu"]["instances"][0]
@pytest.mark.asyncio
@@ -25,15 +30,30 @@ async def test_feishu_login_writes_credentials_to_active_config(monkeypatch, tmp
"domain": "lark",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/avatar.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
channel = FeishuChannel({"enabled": False, "domain": "feishu"}, None)
assert await channel.login() is True
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["appId"] == "cli_app"
assert data["channels"]["feishu"]["appSecret"] == "secret"
assert data["channels"]["feishu"]["domain"] == "lark"
assert data["channels"]["feishu"]["enabled"] is True
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["domain"] == "lark"
assert instance["identityKey"] == "lark:cli_app"
assert instance["enabled"] is True
assert instance["displayName"] == "Voraflare Bot"
assert instance["avatarUrl"] == "https://example.com/avatar.png"
assert instance["identityFetchedAt"] == "2026-07-06T00:00:00Z"
def test_begin_registration_requires_login_url(monkeypatch):
@@ -70,6 +90,298 @@ def test_qr_register_returns_none_on_network_error(monkeypatch):
assert feishu_module.qr_register() is None
def test_save_registration_result_keeps_credentials_when_identity_fetch_fails(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fail_identity(_app_id, _app_secret, _domain):
raise RuntimeError("metadata unavailable")
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", fail_identity)
feishu_module.save_registration_result({
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
})
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = _default_feishu_instance(data)
assert instance["appId"] == "cli_app"
assert instance["appSecret"] == "secret"
assert instance["identityKey"] == "feishu:cli_app"
assert "displayName" not in instance
assert "avatarUrl" not in instance
def test_save_registration_result_resets_access_when_instance_app_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "old assistant",
"enabled": True,
"appId": "cli_old",
"appSecret": "old-secret",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="new assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appId"] == "cli_new"
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_save_registration_result_keeps_access_when_only_secret_rotates(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "same assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "feishu",
"identityKey": "feishu:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="same assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["appSecret"] == "new-secret"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
assert pairing_store.approve_code(pending_code) == (
"feishu.assistant-test",
"pending-user",
)
def test_save_registration_result_resets_access_when_domain_changes(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "lark assistant",
"enabled": True,
"appId": "cli_same",
"appSecret": "old-secret",
"domain": "lark",
"identityKey": "lark:cli_same",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
monkeypatch.setattr(feishu_module, "fetch_feishu_app_identity", lambda *_args: {})
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
feishu_module.save_registration_result(
{
"app_id": "cli_same",
"app_secret": "new-secret",
"domain": "feishu",
},
instance_id="assistant-test",
name="feishu assistant",
)
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["domain"] == "feishu"
assert instance["identityKey"] == "feishu:cli_same"
assert instance["allowFrom"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
def test_sync_saved_identity_boundary_resets_access_after_manual_app_change(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "manual assistant",
"enabled": True,
"appId": "cli_new",
"appSecret": "secret",
"domain": "feishu",
"identityKey": "feishu:cli_old",
"allowFrom": ["old-open-id"],
"allow_from": ["old-snake-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
pending_code = pairing_store.generate_code("feishu.assistant-test", "pending-user")
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_new",
domain="feishu",
) is True
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_new"
assert instance["allowFrom"] == []
assert instance["allow_from"] == []
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is False
assert pairing_store.approve_code(pending_code) is None
def test_sync_saved_identity_boundary_backfills_marker_without_resetting_access(
monkeypatch,
tmp_path,
):
config_path = tmp_path / "config.json"
pairing_path = tmp_path / "pairing.json"
config = Config()
config.channels.feishu = {
"instances": [
{
"id": "assistant-test",
"instanceId": "assistant-test",
"name": "existing assistant",
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
]
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(pairing_store, "_store_path", lambda: pairing_path)
approved_code = pairing_store.generate_code("feishu.assistant-test", "paired-user")
pairing_store.approve_code(approved_code)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="assistant-test",
app_id="cli_existing",
domain="feishu",
) is False
data = json.loads(config_path.read_text(encoding="utf-8"))
instance = data["channels"]["feishu"]["instances"][0]
assert instance["identityKey"] == "feishu:cli_existing"
assert instance["allowFrom"] == ["old-open-id"]
assert pairing_store.is_approved("feishu.assistant-test", "paired-user") is True
def test_sync_saved_identity_boundary_preserves_legacy_flat_config(monkeypatch, tmp_path):
config_path = tmp_path / "config.json"
config = Config()
config.channels.feishu = {
"enabled": True,
"appId": "cli_existing",
"appSecret": "secret",
"domain": "feishu",
"allowFrom": ["old-open-id"],
}
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
assert feishu_module.sync_saved_feishu_identity_boundary(
instance_id="default",
app_id="cli_existing",
domain="feishu",
) is False
saved = json.loads(config_path.read_text(encoding="utf-8"))["channels"]["feishu"]
assert saved["appId"] == "cli_existing"
assert saved["appSecret"] == "secret"
assert saved["identityKey"] == "feishu:cli_existing"
assert saved["allowFrom"] == ["old-open-id"]
assert "instances" not in saved
@pytest.mark.asyncio
async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path):
missing_config = tmp_path / "missing.json"
@@ -89,4 +401,6 @@ async def test_feishu_login_creates_missing_active_config(monkeypatch, tmp_path)
assert await channel.login() is True
assert missing_config.exists()
data = json.loads(missing_config.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["appId"] == "cli_app"
instance = _default_feishu_instance(data)
assert instance["id"] == "default"
assert instance["appId"] == "cli_app"
+118
View File
@@ -44,6 +44,7 @@ def _make_feishu_channel(
channel._client = MagicMock()
# _loop is only used by the WebSocket thread bridge; not needed for unit tests
channel._loop = None
channel._running = True
return channel
@@ -209,6 +210,35 @@ def test_reply_message_sync_returns_false_on_api_error() -> None:
assert ok is False
def test_reply_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
channel._client.im.v1.message.reply.side_effect = [interactive_resp, text_resp]
ok = channel._reply_message_sync(
"om_parent",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert ok is True
assert channel._client.im.v1.message.reply.call_count == 2
def test_reply_message_sync_returns_false_on_exception() -> None:
channel = _make_feishu_channel()
channel._client.im.v1.message.reply.side_effect = RuntimeError("network error")
@@ -368,6 +398,37 @@ async def test_send_fallback_to_create_when_reply_fails() -> None:
channel._client.im.v1.message.create.assert_called_once()
def test_send_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
text_resp.data = SimpleNamespace(message_id="om_fallback")
channel._client.im.v1.message.create.side_effect = [interactive_resp, text_resp]
message_id = channel._send_message_sync(
"chat_id",
"oc_abc",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert message_id == "om_fallback"
assert channel._client.im.v1.message.create.call_count == 2
@pytest.mark.asyncio
async def test_send_multiple_messages_all_use_reply_when_in_topic(tmp_path: Path) -> None:
"""When in a topic (has thread_id), all messages use reply API to stay in topic."""
@@ -786,6 +847,36 @@ async def test_session_key_group_no_root_id_uses_message_id() -> None:
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
@pytest.mark.asyncio
async def test_session_key_named_instance_uses_runtime_channel_namespace() -> None:
"""Named Feishu assistant instances keep group sessions separate."""
channel = _make_feishu_channel(group_policy="open")
channel.name = "feishu.product"
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="group",
content='{"text": "hello"}',
root_id="om_root123",
message_id="om_child456",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].channel == "feishu.product"
assert bus_spy[0].session_key == "feishu.product:oc_abc:om_root123"
@pytest.mark.asyncio
async def test_session_key_private_chat_no_override() -> None:
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
@@ -1036,6 +1127,33 @@ def test_on_background_task_done_removes_from_set() -> None:
assert task not in channel._background_tasks
def test_on_message_sync_ignores_events_after_channel_stops() -> None:
"""Late WebSocket callbacks should not schedule work after the assistant is off."""
channel = _make_feishu_channel()
channel._running = False
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
with patch("asyncio.run_coroutine_threadsafe") as schedule:
channel._on_message_sync(_make_feishu_event())
schedule.assert_not_called()
@pytest.mark.asyncio
async def test_on_message_ignores_events_after_channel_stops() -> None:
"""Stopped assistants must not react, pair, or publish stale Feishu events."""
channel = _make_feishu_channel(group_policy="open")
channel._running = False
channel._add_reaction = AsyncMock()
channel._handle_message = AsyncMock()
await channel._on_message(_make_feishu_event())
channel._add_reaction.assert_not_awaited()
channel._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_message_unauthorized_dm_sends_pairing_code_without_side_effects() -> None:
"""Unauthorized DM sender gets a pairing code but no media side effects."""
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import asyncio
import threading
from nanobot.channels._feishu_ws import FeishuWsRunner
def test_concurrent_loop_initialization_starts_one_thread(monkeypatch) -> None:
runner = FeishuWsRunner()
created_loops: list[asyncio.AbstractEventLoop] = []
release_start = threading.Event()
def fake_run_loop() -> None:
loop = asyncio.new_event_loop()
created_loops.append(loop)
assert release_start.wait(timeout=2)
runner._loop = loop
runner._ready.set()
monkeypatch.setattr(runner, "_run_loop", fake_run_loop)
loops: list[asyncio.AbstractEventLoop] = []
threads = [threading.Thread(target=lambda: loops.append(runner._ensure_loop())) for _ in range(2)]
for thread in threads:
thread.start()
release_start.set()
for thread in threads:
thread.join(timeout=2)
assert len(created_loops) == 1
assert loops == [created_loops[0], created_loops[0]]
created_loops[0].close()
@@ -76,6 +76,7 @@ def _make_handler(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -93,6 +94,7 @@ def _make_handler(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
@@ -108,6 +110,7 @@ def _ch(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Any | None = None,
local_trigger_pending_ids: Any | None = None,
channel_feature_action: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -129,6 +132,7 @@ def _ch(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
@@ -650,6 +654,128 @@ async def test_nanobot_feature_routes_require_token_and_enable(
await server_task
@pytest.mark.asyncio
async def test_pairing_routes_require_token_and_approve_or_deny(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pending = [
{
"code": "ABCD-EFGH",
"channel": "feishu",
"sender_id": "ou_123",
"created_at": 1_000.0,
"expires_at": 1_600.0,
}
]
approved: list[str] = []
denied: list[str] = []
monkeypatch.setattr("nanobot.webui.settings_routes.list_pending", lambda: list(pending))
monkeypatch.setattr(
"nanobot.webui.settings_routes.approve_code",
lambda code: approved.append(code) or ("feishu", "ou_123") if code == "ABCD-EFGH" else None,
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.deny_code",
lambda code: denied.append(code) or code == "ABCD-EFGH",
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
denied_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert denied_response is not None
assert denied_response.status_code == 401
auth = {"Authorization": f"Bearer {token}"}
listed = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing"),
"/api/settings/pairing",
)
assert listed is not None
assert listed.status_code == 200
body = json.loads(listed.body.decode())
assert body["requests"][0]["code"] == "ABCD-EFGH"
assert body["requests"][0]["channel"] == "feishu"
assert body["requests"][0]["sender_id"] == "ou_123"
assert body["requests"][0]["created_at_ms"] == 1_000_000
assert body["requests"][0]["expires_at_ms"] == 1_600_000
approved_response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve?code=ABCD-EFGH"),
"/api/settings/pairing/approve",
)
assert approved_response is not None
assert approved_response.status_code == 200
body = json.loads(approved_response.body.decode())
assert body["last_action"]["action"] == "approve"
assert body["last_action"]["sender_id"] == "ou_123"
assert approved == ["ABCD-EFGH"]
denied_action = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/deny?code=ABCD-EFGH"),
"/api/settings/pairing/deny",
)
assert denied_action is not None
assert denied_action.status_code == 200
assert json.loads(denied_action.body.decode())["last_action"]["action"] == "deny"
assert denied == ["ABCD-EFGH"]
missing_code = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(auth, path="/api/settings/pairing/approve"),
"/api/settings/pairing/approve",
)
assert missing_code is not None
assert missing_code.status_code == 400
assert "Missing pairing code" in missing_code.body.decode()
def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) -> None:
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": "secret-token"})},
path="/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
)
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
assert query == {
"host": ["0.0.0.0"],
"port": ["8900"],
"timeout": ["120"],
"api_key": ["secret-token"],
}
def test_api_service_settings_reject_invalid_private_header(bus: MagicMock) -> None:
from nanobot.webui.settings_api import WebUISettingsError
channel = _ch(bus)
request = _FakeReq(
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": 123})},
path="/api/settings/api-service/start?host=127.0.0.1",
)
with pytest.raises(WebUISettingsError, match="API key must be a string"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
query_secret = _FakeReq(
path="/api/settings/api-service/start?host=127.0.0.1&api_key=secret-token",
)
with pytest.raises(WebUISettingsError, match="private header"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(query_secret)
@pytest.mark.asyncio
async def test_nanobot_feature_remote_install_requires_opt_in(
bus: MagicMock,
@@ -733,6 +859,506 @@ async def test_nanobot_feature_local_install_allowed_by_default(
] is True
@pytest.mark.asyncio
async def test_nanobot_feature_channel_action_can_apply_without_restart(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
_stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"])
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Matrix channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
request = _FakeReq(
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
path="/api/settings/nanobot-features/enable?name=matrix",
)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
request,
"/api/settings/nanobot-features/enable",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert calls == [("enable", "matrix")]
assert body["requires_restart"] is False
assert body["restart_required_sections"] == []
assert body["last_action"]["hot_reload"] is True
assert body["last_action"]["message"].endswith("Matrix channel applied without restart.")
@pytest.mark.asyncio
async def test_feishu_connect_routes_write_config_and_hot_reload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_app",
"app_secret": "secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": "Voraflare Bot",
"avatarUrl": "https://example.com/feishu.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
monkeypatch.setattr(
"nanobot.webui.settings_routes.nanobot_features_action",
lambda _action, _query, *, allow_install=True: {
"features": [{
"name": "feishu",
"display_name": "Feishu",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'feishu'", "enabled": True},
},
)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Feishu channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}
started = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path="/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default",
),
"/api/settings/channels/feishu/connect/start",
)
assert started is not None
assert started.status_code == 200
start_body = json.loads(started.body.decode())
assert start_body["status"] == "pending"
assert start_body["instance_id"] == "default"
assert start_body["qr_url"].startswith("https://accounts.feishu.cn/")
polled = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
auth,
path=f"/api/settings/channels/feishu/connect/poll?session_id={start_body['session_id']}",
),
"/api/settings/channels/feishu/connect/poll",
)
assert polled is not None
assert polled.status_code == 200
body = json.loads(polled.body.decode())
assert body["status"] == "succeeded"
assert body["instance_id"] == "default"
assert "app_secret" not in body
assert calls == [("enable", "feishu")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["feishu"]["instances"][0]["id"] == "default"
assert data["channels"]["feishu"]["instances"][0]["appId"] == "cli_app"
assert data["channels"]["feishu"]["instances"][0]["appSecret"] == "secret"
assert data["channels"]["feishu"]["instances"][0]["enabled"] is True
assert data["channels"]["feishu"]["instances"][0]["displayName"] == "Voraflare Bot"
assert data["channels"]["feishu"]["instances"][0]["avatarUrl"] == "https://example.com/feishu.png"
def test_feishu_connect_create_appends_instance(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.channels import feishu as feishu_module
from nanobot.config import loader
from nanobot.webui.channel_connect import FeishuConnectStore
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({
"channels": {
"feishu": {
"instances": [{
"id": "default",
"name": "nanobot",
"enabled": True,
"appId": "cli_default",
"appSecret": "default-secret",
}]
}
}
}),
encoding="utf-8",
)
monkeypatch.setattr(loader, "_current_config_path", config_path)
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
monkeypatch.setattr(
feishu_module,
"_begin_registration",
lambda _domain: {
"device_code": "device",
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
"interval": 2,
"expire_in": 600,
},
)
monkeypatch.setattr(
feishu_module,
"poll_registration_once",
lambda *, device_code, domain: {
"status": "succeeded",
"app_id": "cli_new",
"app_secret": "new-secret",
"domain": "feishu",
},
)
monkeypatch.setattr(
feishu_module,
"fetch_feishu_app_identity",
lambda app_id, app_secret, domain: {
"displayName": f"Assistant {app_id}",
"avatarUrl": f"https://example.com/{app_id}.png",
"identityFetchedAt": "2026-07-06T00:00:00Z",
},
)
store = FeishuConnectStore()
started = store.start(mode="create")
polled = store.poll(started["session_id"])
assert polled["status"] == "succeeded"
assert polled["instance_id"] != "default"
data = json.loads(config_path.read_text(encoding="utf-8"))
instances = data["channels"]["feishu"]["instances"]
assert [item["id"] for item in instances] == ["default", polled["instance_id"]]
assert instances[0]["appId"] == "cli_default"
assert instances[1]["appId"] == "cli_new"
assert instances[0].get("displayName") is None
assert instances[1]["displayName"] == "Assistant cli_new"
assert instances[1]["avatarUrl"] == "https://example.com/cli_new.png"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
def fake_feature_action(
action: str,
query: dict[str, list[str]],
*,
allow_install: bool = True,
) -> dict[str, Any]:
assert action == "enable"
assert query == {"name": ["discord"]}
cfg = loader.load_config()
section = dict(getattr(cfg.channels, "discord", {}) or {})
section["enabled"] = True
setattr(cfg.channels, "discord", section)
loader.save_config(cfg)
return {
"features": [{
"name": "discord",
"display_name": "Discord",
"type": "channel",
"enabled": True,
"installed": True,
"ready": True,
"status": "enabled",
"install_supported": True,
"requires_restart": True,
}],
"enabled_count": 1,
"requires_restart": True,
"last_action": {"ok": True, "message": "Enabled channel 'discord'", "enabled": True},
}
monkeypatch.setattr("nanobot.webui.settings_routes.nanobot_features_action", fake_feature_action)
calls: list[tuple[str, str]] = []
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
calls.append((action, name))
cfg = loader.load_config()
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": "Discord channel applied without restart.",
}
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
port=_free_port(),
channel_feature_action=channel_feature_action,
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "discord-token",
"channels.discord.allowChannels": "123, 456",
"channels.discord.groupPolicy": "open",
}
),
},
path="/api/settings/channels/configure?name=discord&enable=true",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved"] is True
assert body["name"] == "discord"
assert "discord-token" not in response.body.decode()
assert calls == [("enable", "discord")]
assert body["nanobot_features"]["requires_restart"] is False
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"token": "discord-token",
"allowChannels": ["123", "456"],
"groupPolicy": "open",
"enabled": True,
}
@pytest.mark.asyncio
async def test_channel_configure_route_preserves_existing_channel_values(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"discord",
{
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["old-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.discord.token": "",
"channels.discord.allowChannels": "new-channel",
}
),
},
path="/api/settings/channels/configure?name=discord",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
body = json.loads(response.body.decode())
assert body["saved_keys"] == ["channels.discord.allowChannels"]
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["discord"] == {
"enabled": True,
"token": "old-discord-token",
"allowChannels": ["new-channel"],
"groupPolicy": "mention",
"customExtra": "keep-me",
"nested": {"value": 42},
}
@pytest.mark.asyncio
async def test_channel_configure_route_saves_matrix_device_id_without_replacing_token(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
config = Config()
setattr(
config.channels,
"matrix",
{
"enabled": False,
"homeserver": "https://matrix.example",
"userId": "@nanobot:matrix.example",
"accessToken": "saved-token",
},
)
loader.save_config(config, config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.matrix.accessToken": "",
"channels.matrix.deviceId": "DEVICE-ID",
}
),
},
path="/api/settings/channels/configure?name=matrix",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["matrix"]["accessToken"] == "saved-token"
assert data["channels"]["matrix"]["deviceId"] == "DEVICE-ID"
@pytest.mark.asyncio
async def test_channel_configure_route_saves_mattermost_setup(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from nanobot.config import loader
from nanobot.config.schema import Config
config_path = tmp_path / "config.json"
loader.save_config(Config(), config_path)
monkeypatch.setattr(loader, "_current_config_path", config_path)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.settings_routes.dispatch(
_LOCAL,
_FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
"X-Nanobot-Channel-Values": json.dumps(
{
"channels.mattermost.serverUrl": "https://chat.example.com",
"channels.mattermost.token": "mattermost-token",
"channels.mattermost.teamId": "platform",
}
),
},
path="/api/settings/channels/configure?name=mattermost",
),
"/api/settings/channels/configure",
)
assert response is not None
assert response.status_code == 200
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["channels"]["mattermost"] == {
"serverUrl": "https://chat.example.com",
"token": "mattermost-token",
"teamId": "platform",
}
@pytest.mark.asyncio
async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in(
bus: MagicMock,
+180 -1
View File
@@ -7,6 +7,7 @@ from contextlib import suppress
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from typer.testing import CliRunner
@@ -1607,6 +1608,12 @@ def _patch_webui_provider_ready(monkeypatch) -> None:
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
def _patch_gateway_ports_free(monkeypatch) -> None:
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_a, **_kw: False)
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_a, **_kw: False)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_a, **_kw: False)
def _patch_cli_command_runtime(
monkeypatch,
config: Config,
@@ -1643,6 +1650,7 @@ def _patch_cli_command_runtime(
"nanobot.providers.factory.load_provider_snapshot",
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
)
_patch_gateway_ports_free(monkeypatch)
if message_bus is not None:
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
@@ -1701,11 +1709,17 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
assert len(websocket["tokenIssueSecret"]) >= 32
assert data["agents"]["defaults"]["workspace"] == str(workspace)
assert seen["templates"] == workspace
assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None}
assert seen["gateway_kwargs"] == {
"port": 18888,
"open_browser_url": None,
"webui_bundle_mode": "auto",
}
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
assert "bootstrap secret was generated" in compact_output
assert "channels.websocket.tokenIssueSecret" in compact_output
assert "rerun without --no-open" in compact_output
assert "nanobot is running in this terminal" in compact_output
assert "Press Ctrl+C here to stop nanobot" in compact_output
def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None:
@@ -1732,6 +1746,10 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
@@ -1785,6 +1803,21 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
assert "bootstrapSecret=<redacted>" in compact_output
assert "bootstrapSecret=" in opened_url
assert "Closing the browser does not stop channels or automations" in compact_output
assert "nanobot gateway stop --config" in compact_output
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
opened: list[str] = []
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
cli_commands._open_webui_browser(url, wait=False)
assert opened == [url]
output = _strip_ansi(capsys.readouterr().out)
assert "bootstrapSecret=<redacted>" in output
assert "super-secret" not in output
def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
@@ -1799,6 +1832,10 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
def _status(options: GatewayStartOptions) -> GatewayStatus:
return GatewayStatus(
@@ -1861,6 +1898,125 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr(
"nanobot.cli.commands._open_webui_browser",
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
)
monkeypatch.setattr(
"nanobot.cli.commands._run_gateway",
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
)
class _FakeRuntime:
def __init__(self, **kwargs) -> None:
seen["runtime_kwargs"] = kwargs
def status(self):
return SimpleNamespace(running=True)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.commands._attach_to_background_gateway",
lambda runtime: seen.__setitem__("attached_runtime", runtime),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 0
assert "Gateway is already running; attaching to the existing WebUI" in result.stdout
assert isinstance(seen["attached_runtime"], _FakeRuntime)
opened_url = seen["opened_url"]
assert isinstance(opened_url, str)
parsed = urlparse(opened_url)
assert f"{parsed.scheme}://{parsed.netloc}" == "http://127.0.0.1:8765"
fragment = parsed.fragment.removeprefix("/?")
assert parse_qs(fragment).get("bootstrapSecret")
assert seen["open_kwargs"] == {"wait": False}
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
stopped = False
class _FakeRuntime:
def status(self):
return SimpleNamespace(running=True)
def stop(self):
nonlocal stopped
stopped = True
return SimpleNamespace(ok=True, message="gateway_stopped")
def _interrupt(_seconds: float) -> None:
raise KeyboardInterrupt
monkeypatch.setattr("nanobot.cli.commands.time.sleep", _interrupt)
cli_commands._attach_to_background_gateway(_FakeRuntime())
assert stopped is True
output = capsys.readouterr().out
assert "Closing the browser does not stop channels or automations" in output
assert "Press Ctrl+C here to stop nanobot" in output
assert "Gateway stopped" in output
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args: True)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args: True)
monkeypatch.setattr("nanobot.cli.commands._open_webui_browser", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"nanobot.cli.commands._attach_to_background_gateway",
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
)
class _FakeRuntime:
def __init__(self, **_kwargs) -> None:
pass
def status(self):
return SimpleNamespace(running=False)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 0
assert "controlled by another foreground command" in result.stdout
def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: False)
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
monkeypatch.setattr(
"nanobot.cli.commands._run_gateway",
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
)
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
assert result.exit_code == 1
assert "nanobot cannot start because one of its local ports is already in use" in result.stdout
assert "--port" in result.stdout
assert "--gateway-port" in result.stdout
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
pytest.importorskip("aiohttp")
@@ -1998,6 +2154,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
@@ -2124,6 +2281,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
_patch_gateway_ports_free(monkeypatch)
monkeypatch.setattr(
"nanobot.providers.factory.build_provider_snapshot",
lambda _config: _test_provider_snapshot(provider, _config),
@@ -3062,6 +3220,27 @@ def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path
assert "api_app" not in seen
def test_serve_rejects_specific_network_interface_without_api_key(
monkeypatch,
tmp_path: Path,
) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
seen: dict[str, object] = {}
_patch_serve_runtime(monkeypatch, config, seen)
result = runner.invoke(
app,
["serve", "--config", str(config_file), "--host", "192.168.1.10"],
)
assert result.exit_code == 1
assert "api_key" in result.stdout
assert "prevent unauthenticated access" in result.stdout
assert "api_app" not in seen
def test_channels_login_requires_channel_name() -> None:
result = runner.invoke(app, ["channels", "login"])
+26 -13
View File
@@ -88,13 +88,22 @@ def _test_app(tmp_path: Path, config: Config | None = None):
app = typer.Typer()
fake_runtime = FakeRuntime(tmp_path)
fake_service = FakeServiceInstaller(tmp_path)
run_calls: list[tuple[Config, int | None]] = []
run_calls: list[tuple[Config, int | None, str | None]] = []
prepare_calls: list[tuple[Config, str]] = []
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
return config or Config()
def run_gateway(config: Config, *, port: int | None = None) -> None:
run_calls.append((config, port))
def run_gateway(
config: Config,
*,
port: int | None = None,
webui_bundle_mode: str | None = None,
) -> None:
run_calls.append((config, port, webui_bundle_mode))
def prepare_webui_bundle(config: Config, mode: str) -> None:
prepare_calls.append((config, mode))
app.add_typer(
create_gateway_app(
@@ -104,36 +113,39 @@ def _test_app(tmp_path: Path, config: Config | None = None):
run_gateway=run_gateway,
runtime_factory=lambda **_kwargs: fake_runtime,
service_factory=lambda: fake_service,
prepare_webui_bundle=prepare_webui_bundle,
),
name="gateway",
)
return app, fake_runtime, fake_service, run_calls
return app, fake_runtime, fake_service, run_calls, prepare_calls
def test_gateway_default_still_runs_foreground(tmp_path):
app, _runtime, _service, calls = _test_app(tmp_path)
app, _runtime, _service, calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "--port", "18791"])
assert result.exit_code == 0
assert len(calls) == 1
assert calls[0][1] == 18791
assert calls[0][2] == "warn"
def test_gateway_background_starts_detached_runtime(tmp_path):
config = Config()
config.gateway.port = 18792
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "--background"])
assert result.exit_code == 0
assert "Gateway started in the background" in result.stdout
assert fake_runtime.started_options == GatewayStartOptions(port=18792)
assert prepare_calls == [(config, "warn")]
def test_gateway_rejects_conflicting_modes(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "--foreground", "--background"])
@@ -142,7 +154,7 @@ def test_gateway_rejects_conflicting_modes(tmp_path):
def test_gateway_status_uses_runtime(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "status"])
@@ -152,7 +164,7 @@ def test_gateway_status_uses_runtime(tmp_path):
def test_gateway_logs_can_read_without_following(tmp_path):
app, _runtime, _service, _calls = _test_app(tmp_path)
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(app, ["gateway", "logs", "--tail", "12", "--no-follow"])
@@ -161,7 +173,7 @@ def test_gateway_logs_can_read_without_following(tmp_path):
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
app, fake_runtime, _service, _calls = _test_app(tmp_path)
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
def fake_stop(*, timeout_s: int) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s
@@ -179,7 +191,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
def test_gateway_restart_starts_background_runtime(tmp_path):
config = Config()
config.gateway.port = 18793
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "restart", "--timeout", "9", "--verbose"])
@@ -187,12 +199,13 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
assert "Gateway restarted in the background" in result.stdout
assert fake_runtime.stop_timeout == 9
assert fake_runtime.restarted_options == GatewayStartOptions(port=18793, verbose=True)
assert prepare_calls == [(config, "warn")]
def test_gateway_install_service_uses_service_installer(tmp_path):
config = Config()
config.gateway.port = 18794
app, _runtime, service, _calls = _test_app(tmp_path, config=config)
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path, config=config)
result = runner.invoke(app, ["gateway", "install-service", "--dry-run", "--manager", "systemd"])
@@ -205,7 +218,7 @@ def test_gateway_install_service_uses_service_installer(tmp_path):
def test_gateway_uninstall_service_uses_service_installer(tmp_path):
app, _runtime, service, _calls = _test_app(tmp_path)
app, _runtime, service, _calls, _prepare_calls = _test_app(tmp_path)
result = runner.invoke(
app,
+57
View File
@@ -0,0 +1,57 @@
from pathlib import Path
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
class FakeProcess:
pid = 23456
def test_api_runtime_uses_isolated_paths(tmp_path: Path) -> None:
paths = api_runtime_paths(tmp_path / "config.json")
assert paths.state_path.parent == tmp_path / "run"
assert paths.state_path.name.startswith("api.")
assert paths.log_path.parent == tmp_path / "logs"
def test_api_runtime_builds_detached_serve_command(tmp_path: Path, monkeypatch) -> None:
calls: list[list[str]] = []
def fake_popen(command, **_kwargs):
calls.append(command)
return FakeProcess()
runtime = ApiRuntime(
paths=api_runtime_paths(tmp_path / "config.json"),
platform_name="Linux",
python_executable="/python",
popen=fake_popen,
sleep=lambda _seconds: None,
)
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 23456)
result = runtime.start_background(ApiStartOptions(
host="0.0.0.0",
port=9900,
workspace="/tmp/workspace",
config_path="/tmp/config.json",
))
assert result.ok is True
assert result.message == "api_started_background"
assert calls == [[
"/python",
"-m",
"nanobot",
"serve",
"--host",
"0.0.0.0",
"--port",
"9900",
"--workspace",
"/tmp/workspace",
"--config",
"/tmp/config.json",
]]
+112 -2
View File
@@ -1,9 +1,13 @@
import json
import signal
import subprocess
import sys
import threading
from pathlib import Path
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
import pytest
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions, GatewayStatus
class FakeProcess:
@@ -83,6 +87,57 @@ def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
assert state["port"] == 18790
def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypatch):
first_spawned = threading.Event()
release_first = threading.Event()
calls: list[list[str]] = []
def fake_popen(command, **_kwargs):
calls.append(command)
first_spawned.set()
return FakeProcess()
def first_sleep(_seconds):
assert release_first.wait(timeout=2)
first = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=fake_popen,
sleep=first_sleep,
)
second = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=fake_popen,
sleep=lambda _seconds: None,
)
for runtime in (first, second):
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
results = []
first_thread = threading.Thread(
target=lambda: results.append(first.start_background(GatewayStartOptions(port=18790)))
)
second_thread = threading.Thread(
target=lambda: results.append(second.start_background(GatewayStartOptions(port=18790)))
)
first_thread.start()
assert first_spawned.wait(timeout=2)
second_thread.start()
release_first.set()
first_thread.join(timeout=2)
second_thread.join(timeout=2)
assert len(calls) == 1
assert sorted((result.ok, result.message) for result in results) == [
(False, "gateway_already_running"),
(True, "gateway_started_background"),
]
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
calls: list[dict] = []
@@ -172,6 +227,34 @@ def test_stop_keeps_state_when_process_survives_timeout(tmp_path, monkeypatch):
assert runtime.paths.state_path.exists()
def test_stop_succeeds_when_process_exits_at_timeout_boundary(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
running = GatewayStatus(
running=True,
pid=12345,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
)
stopped = GatewayStatus(
running=False,
pid=None,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
reason="stop_timeout",
)
statuses = iter([running, stopped])
monkeypatch.setattr(runtime, "status", lambda **_kwargs: next(statuses))
monkeypatch.setattr(runtime, "_read_state", lambda: {"pid": 12345, "identity": 12345})
monkeypatch.setattr(runtime, "_record_matches_process", lambda *_args: True)
monkeypatch.setattr(runtime, "_terminate", lambda *_args, **_kwargs: False)
result = runtime.stop(timeout_s=0)
assert result.ok is True
assert result.message == "gateway_stopped"
assert result.status.running is False
def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monkeypatch):
taskkill_calls: list[dict] = []
wait_timeouts: list[int | float] = []
@@ -191,7 +274,7 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk
def fake_kill(_pid, _signal):
raise OSError(87, "The parameter is incorrect")
monkeypatch.setattr("nanobot.gateway.runtime.os.kill", fake_kill)
monkeypatch.setattr("nanobot.process_runtime.os.kill", fake_kill)
def fake_wait_for_exit(_pid, _timeout_s):
wait_timeouts.append(_timeout_s)
@@ -212,3 +295,30 @@ def test_terminate_windows_falls_back_when_ctrl_break_is_rejected(tmp_path, monk
},
}
]
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX process groups are unavailable")
def test_terminate_posix_tolerates_process_group_disappearing_before_sigkill(
tmp_path,
monkeypatch,
) -> None:
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Darwin",
sleep=lambda _seconds: None,
)
waits = iter([False, True])
monkeypatch.setattr(
"nanobot.process_runtime.os.getpgid",
lambda _pid: 1234,
raising=False,
)
def fake_killpg(_pgid, sent_signal):
if sent_signal == signal.SIGKILL:
raise PermissionError(1, "Operation not permitted")
monkeypatch.setattr("nanobot.process_runtime.os.killpg", fake_killpg, raising=False)
monkeypatch.setattr(runtime, "_wait_for_exit", lambda *_args: next(waits))
assert runtime._terminate_posix(1234, timeout_s=1) is True
+24
View File
@@ -42,6 +42,15 @@ class TestGenerateCode:
assert store.approve_code(code2) is None
class TestFormatPairingReply:
def test_points_owner_to_webui_with_command_fallback(self) -> None:
reply = store.format_pairing_reply("ABCD-EFGH")
assert "nanobot WebUI" in reply
assert "ABCD-EFGH" in reply
assert "/pairing approve ABCD-EFGH" in reply
class TestApproveDeny:
def test_approve_moves_to_approved(self) -> None:
code = store.generate_code("telegram", "123")
@@ -82,6 +91,21 @@ class TestRevoke:
def test_revoke_unknown_returns_false(self) -> None:
assert store.revoke("telegram", "999") is False
def test_clear_channel_removes_approved_and_pending(self) -> None:
code = store.generate_code("telegram", "123")
store.approve_code(code)
store.generate_code("telegram", "456")
store.generate_code("discord", "789")
assert store.clear_channel("telegram") == {"approved": 1, "pending": 1}
assert store.is_approved("telegram", "123") is False
pending = store.list_pending()
assert [item["channel"] for item in pending] == ["discord"]
def test_clear_channel_unknown_returns_zero_counts(self) -> None:
assert store.clear_channel("telegram") == {"approved": 0, "pending": 0}
class TestListPending:
def test_empty(self) -> None:
+17
View File
@@ -13,6 +13,7 @@ from nanobot.security.network import (
contains_internal_url,
env_proxy_applies_to_url,
httpx_env_proxy_mounts,
is_loopback_host,
pin_resolved_url_dns,
resolve_url_target,
validate_url_target,
@@ -21,6 +22,22 @@ from nanobot.security.network import (
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
@pytest.mark.parametrize(
"host",
["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"],
)
def test_is_loopback_host_accepts_explicit_loopback(host: str) -> None:
assert is_loopback_host(host)
@pytest.mark.parametrize(
"host",
["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"],
)
def test_is_loopback_host_rejects_network_targets(host: str) -> None:
assert not is_loopback_host(host)
@pytest.fixture(autouse=True)
def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
for name in (*_PROXY_ENV_VARS, "NO_PROXY", "no_proxy"):
+75
View File
@@ -1,10 +1,15 @@
"""Tests for document text extraction utilities."""
from pathlib import Path
from zipfile import ZipFile
import pytest
from nanobot.utils.document import (
SUPPORTED_EXTENSIONS,
PdfSafetyError,
_is_text_extension,
extract_pdf_pages,
extract_text,
)
@@ -252,6 +257,76 @@ class TestExtractText:
assert result is not None
assert "Inside group" in result
def test_extract_text_rejects_oversized_office_archive(self, tmp_path, monkeypatch):
office_file = tmp_path / "oversized.docx"
with ZipFile(office_file, "w") as archive:
archive.writestr("word/document.xml", "x" * 32)
monkeypatch.setattr("nanobot.utils.document._MAX_OFFICE_UNCOMPRESSED_SIZE", 16)
assert "Office document expands beyond" in (extract_text(office_file) or "")
def test_extract_text_stops_streaming_xlsx_at_text_limit(self, tmp_path, monkeypatch):
from openpyxl import Workbook, load_workbook
xlsx_file = tmp_path / "large.xlsx"
wb = Workbook(write_only=True)
ws = wb.create_sheet()
for index in range(100):
ws.append([f"row-{index}-" + "x" * 20])
wb.save(xlsx_file)
visited = 0
real_load_workbook = load_workbook
def tracked_load_workbook(*args, **kwargs):
workbook = real_load_workbook(*args, **kwargs)
worksheet = workbook[workbook.sheetnames[0]]
original_iter_rows = worksheet.iter_rows
def tracked_rows(*row_args, **row_kwargs):
nonlocal visited
for row in original_iter_rows(*row_args, **row_kwargs):
visited += 1
yield row
worksheet.iter_rows = tracked_rows
return workbook
monkeypatch.setattr("openpyxl.load_workbook", tracked_load_workbook)
monkeypatch.setattr("nanobot.utils.document._MAX_TEXT_LENGTH", 80)
result = extract_text(xlsx_file)
assert result is not None
assert "truncated at 80 chars" in result
assert visited < 100
def test_extract_pdf_pages_rejects_large_content_stream(self, tmp_path, monkeypatch):
class _Contents:
@staticmethod
def get_data():
return b"x" * 17
class _Page:
@staticmethod
def get_contents():
return _Contents()
@staticmethod
def extract_text():
return "should not be reached"
class _Reader:
def __init__(self, *_args, **_kwargs):
self.pages = [_Page()]
monkeypatch.setattr("pypdf.PdfReader", _Reader)
monkeypatch.setattr("nanobot.utils.document._MAX_PDF_CONTENT_STREAM_SIZE", 16)
with pytest.raises(PdfSafetyError, match="content stream exceeds"):
extract_pdf_pages(tmp_path / "large.pdf")
def test_extract_text_pdf_not_found(self, tmp_path: Path):
"""Test that missing PDF files return error string."""
missing_pdf = tmp_path / "nonexistent.pdf"
+14 -1
View File
@@ -6,8 +6,8 @@ from unittest.mock import patch
import pytest
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
from nanobot.agent.tools import file_state
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
@pytest.fixture(autouse=True)
@@ -197,6 +197,19 @@ class TestReadPdf:
assert "Page 3 content" in result
assert "Page 1 content" not in result
@pytest.mark.asyncio
async def test_pdf_rejects_invalid_page_range(self, tool, tmp_path):
fitz = pytest.importorskip("fitz")
pdf_path = tmp_path / "test.pdf"
doc = fitz.open()
doc.new_page()
doc.save(str(pdf_path))
doc.close()
result = await tool.execute(path=str(pdf_path), pages="bad")
assert "Invalid page range" in result
@pytest.mark.asyncio
async def test_pdf_file_not_found_error(self, tool, tmp_path):
result = await tool.execute(path=str(tmp_path / "nope.pdf"))
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import os
from pathlib import Path
from nanobot.webui.build import ensure_webui_bundle, inspect_webui_bundle
_MTIME_BASE_NS = 1_700_000_000_000_000_000
_MTIME_STEP_NS = 5_000_000_000
def _touch(path: Path, *, mtime_ns: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(path.name, encoding="utf-8")
if mtime_ns < 1_000_000_000_000_000:
mtime_ns = _MTIME_BASE_NS + mtime_ns * _MTIME_STEP_NS
os.utime(path, ns=(mtime_ns, mtime_ns))
def test_inspect_webui_bundle_ignores_packaged_install_without_source(tmp_path: Path) -> None:
source = tmp_path / "site-packages" / "webui"
dist = tmp_path / "site-packages" / "nanobot" / "web" / "dist"
_touch(dist / "index.html", mtime_ns=20)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.source_available is False
assert status.stale is False
assert status.reason == "no_source"
def test_inspect_webui_bundle_marks_missing_dist_stale(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.source_available is True
assert status.dist_available is False
assert status.stale is True
assert status.reason == "missing_dist"
def test_inspect_webui_bundle_detects_source_newer_than_dist(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=30)
_touch(dist / "index.html", mtime_ns=20)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.stale is True
assert status.reason == "source_newer"
assert status.newest_source == source / "src" / "App.tsx"
def test_inspect_webui_bundle_accepts_fresh_dist(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=20)
_touch(dist / "index.html", mtime_ns=30)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.stale is False
assert status.reason == "fresh"
def test_ensure_webui_bundle_auto_builds_stale_dist(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=30)
_touch(dist / "index.html", mtime_ns=20)
commands: list[tuple[str, ...]] = []
def fake_run(command, *, cwd: Path, check: bool) -> None:
commands.append(tuple(command))
assert cwd == source
assert check is True
if command == ["bun", "run", "build"]:
_touch(dist / "index.html", mtime_ns=40)
status = ensure_webui_bundle(
mode="auto",
source_dir=source,
dist_dir=dist,
runner="bun",
subprocess_run=fake_run,
)
assert status.stale is False
assert commands == [("bun", "install"), ("bun", "run", "build")]
def test_ensure_webui_bundle_warns_without_building(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
_touch(source / "package.json", mtime_ns=10)
_touch(source / "src" / "App.tsx", mtime_ns=30)
_touch(dist / "index.html", mtime_ns=20)
messages: list[str] = []
status = ensure_webui_bundle(
mode="warn",
source_dir=source,
dist_dir=dist,
output=messages.append,
)
assert status.stale is True
assert messages
assert "Run `cd" in messages[0]
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
import json
from typing import Any
import pytest
from nanobot.channels.weixin import WeixinChannel
from nanobot.config.loader import save_config
from nanobot.config.schema import Config
from nanobot.webui.channel_connect import WeixinConnectStore
@pytest.mark.asyncio
async def test_weixin_connect_store_saves_confirmed_qr_login(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
self: WeixinChannel,
*,
base_url: str,
endpoint: str,
params: dict[str, Any],
auth: bool,
) -> dict[str, str]:
assert base_url == "https://ilinkai.weixin.qq.com"
assert endpoint == "ilink/bot/get_qrcode_status"
assert params == {"qrcode": "qr-1"}
assert auth is False
return {
"status": "confirmed",
"bot_token": "wx-token",
"baseurl": "https://weixin.example",
"ilink_user_id": "wx-user",
}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start()
assert started["status"] == "pending"
assert started["qr_url"] == "https://qr.example/1"
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
assert completed["account"] == "wx-user"
saved = json.loads((state_dir / "account.json").read_text())
assert saved["token"] == "wx-token"
assert saved["base_url"] == "https://weixin.example"
@pytest.mark.asyncio
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
existing = {
"token": "working-token",
"base_url": "https://working.weixin.example",
"context_tokens": {"user-1": "context-1"},
}
state_file = state_dir / "account.json"
state_file.write_text(json.dumps(existing), encoding="utf-8")
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-reconnect", "https://qr.example/reconnect"
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
store = WeixinConnectStore()
started = await store.start(force=True)
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
cancelled = await store.cancel(started["session_id"])
assert cancelled["status"] == "cancelled"
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
import httpx
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui import channel_validation
from nanobot.webui.channel_validation import validate_channel_config
def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate(
{
"channels": {
"slack": {
"appToken": "xapp-old",
"botToken": "xoxb-old",
"groupPolicy": "mention",
}
}
}
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(channel_validation, "_http_post", lambda *_args, **_kwargs: {"ok": True})
payload = validate_channel_config(
"slack",
{
"channels.slack.appToken": "",
"channels.slack.botToken": "",
},
)
assert payload["status"] == "connected"
saved = load_config(config_path)
assert saved.channels.slack["appToken"] == "xapp-old"
assert saved.channels.slack["botToken"] == "xoxb-old"
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"})
assert payload["status"] == "invalid"
assert payload["can_enable"] is False
assert payload["missing_fields"] == []
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"telegram": {"token": token}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(url: str, **_kwargs) -> dict:
request = httpx.Request("GET", url)
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
monkeypatch.setattr(channel_validation, "_http_get", raise_http_error)
payload = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert token not in str(payload)
assert any("HTTP 401" in check.get("message", "") for check in payload["checks"])
def test_validate_email_presets_are_checked_without_saving(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(channel_validation, "_probe_tcp", lambda *_args, **_kwargs: None)
payload = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
assert payload["status"] == "connected"
assert payload["can_enable"] is True
assert not hasattr(load_config(config_path).channels, "email")
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.webui_allow_local_service_access = False
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
channel_validation.socket,
"create_connection",
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
)
payload = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "127.0.0.1",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "192.168.1.10",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
warnings = [check["message"] for check in payload["checks"] if check["status"] == "warn"]
assert len(warnings) == 2
assert all("private/internal" in message for message in warnings)
def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None:
connected: list[tuple[str, int]] = []
class FakeSocket:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
monkeypatch.setattr(
channel_validation,
"resolve_url_target",
lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)),
)
monkeypatch.setattr(
channel_validation.socket,
"create_connection",
lambda target, **_kwargs: connected.append(target) or FakeSocket(),
)
channel_validation._probe_tcp("mail.example.com", 2525)
assert connected == [("203.0.113.10", 2525)]
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{
"channels": {
"dingtalk": {
"clientId": "ding-client",
"clientSecret": "ding-secret",
}
}
}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config("dingtalk", {})
assert payload["status"] == "configured"
assert payload["can_enable"] is True
assert any(check["status"] == "skipped" for check in payload["checks"])
@pytest.mark.parametrize(
("credentials", "expected_status", "expected_missing"),
[
({}, "needs_setup", "password_or_accessToken"),
({"channels.matrix.accessToken": "token"}, "needs_setup", "deviceId"),
({"channels.matrix.password": "secret"}, "configured", None),
(
{
"channels.matrix.accessToken": "token",
"channels.matrix.deviceId": "DEVICE",
},
"configured",
None,
),
],
)
def test_validate_matrix_requires_a_complete_login_method(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
credentials: dict[str, str],
expected_status: str,
expected_missing: str | None,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config(
"matrix",
{
"channels.matrix.homeserver": "https://matrix.example",
"channels.matrix.userId": "@nanobot:matrix.example",
**credentials,
},
)
assert payload["status"] == expected_status
assert payload["can_enable"] is (expected_status == "configured")
if expected_missing is None:
assert payload["missing_fields"] == []
else:
assert expected_missing in payload["missing_fields"]
+153
View File
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
_docs_version,
_model_catalog_kind,
_oauth_provider_status,
create_model_configuration,
@@ -20,6 +21,7 @@ from nanobot.webui.settings_api import (
settings_payload,
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
@@ -31,6 +33,100 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
assert _docs_version("0.2.3") == "0.2.3"
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
assert _docs_version("0.2.3.dev0") == "latest"
assert _docs_version("0.2.3+editable") == "latest"
def test_settings_payload_includes_versioned_docs(
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("nanobot.webui.settings_api.__version__", "0.2.3")
payload = settings_payload()
assert payload["docs"] == {
"version": "0.2.3",
"base_url": "https://nanobot.wiki/docs/0.2.3",
"chat_apps_url": "https://nanobot.wiki/docs/0.2.3/getting-started/chat-apps",
"latest_url": "https://nanobot.wiki/docs/latest",
}
def test_settings_payload_includes_relocated_capabilities(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.api.port = 9910
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
payload = settings_payload()
assert payload["api"]["port"] == 9910
assert payload["api"]["api_key_hint"] is None
assert payload["observability"]["provider"] == "langfuse"
assert payload["observability"]["configured"] is True
def test_update_api_settings_requires_key_for_network_access(
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)
with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
payload = update_api_settings({
"host": ["0.0.0.0"],
"port": ["9900"],
"api_key": ["secret-token"],
})
saved = load_config(config_path)
assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900
assert saved.api.api_key == "secret-token"
assert payload["api"]["api_key_hint"]
def test_update_api_settings_requires_key_for_specific_network_interface(
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)
with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["192.168.1.10"], "port": ["8900"]})
def test_update_api_settings_allows_alternate_loopback_without_key(
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)
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
assert load_config(config_path).api.host == "127.0.0.2"
def _dynamic_provider_config(
*,
api_base: str = DYNAMIC_PROVIDER_API_BASE,
@@ -348,6 +444,42 @@ def test_settings_payload_includes_dynamic_custom_provider(
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == DYNAMIC_PROVIDER_API_BASE
def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
opencode_rows = [row for row in payload["providers"] if row["label"].startswith("OpenCode")]
assert [(row["name"], row["label"]) for row in opencode_rows] == [
("opencode", "OpenCode Zen"),
("opencode_go", "OpenCode Go"),
]
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate({
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"agents": {
"defaults": {
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
}
},
})
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
zen_rows = [row for row in payload["providers"] if row["label"] == "OpenCode Zen"]
assert len(zen_rows) == 1
assert zen_rows[0]["name"] == "opencode_zen"
assert zen_rows[0]["configured"] is True
def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfigured(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -952,6 +1084,26 @@ def test_provider_models_payload_fetches_openai_compatible_models(
assert payload["models"][1]["context_window"] == 65536
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
payload = provider_models_payload({"provider": ["openai_codex"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin"
assert payload["model_count"] == 7
assert payload["models"][0] == {
"id": "openai-codex/gpt-5.6-sol",
"label": "GPT-5.6-Sol",
"description": "Latest frontier agentic coding model.",
"owned_by": "OpenAI Codex",
"context_window": 372000,
}
assert [model["id"] for model in payload["models"][:3]] == [
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
]
def test_provider_models_payload_fetches_dynamic_custom_provider_models(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -1044,6 +1196,7 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
assert _model_catalog_kind(find_by_name("skywork")) == "official"
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
def test_create_model_configuration_accepts_configured_oauth_provider(
+235 -4
View File
@@ -16,6 +16,7 @@
"diff": "^9.0.0",
"i18next": "^26.0.6",
"lucide-react": "^0.469.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
@@ -28,22 +29,28 @@
"tailwind-merge": "^2.6.0",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.10.5",
"@types/node": "^24.0.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"happy-dom": "^16.3.0",
"katex": "^0.16.21",
"postcss": "^8.5.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"typescript-eslint": "^8.59.4",
"vite": "^5.4.11",
"vitest": "^2.1.8",
},
@@ -140,6 +147,22 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@@ -148,6 +171,16 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -300,22 +333,28 @@
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
@@ -324,6 +363,26 @@
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.62.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.62.1", "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1" } }, "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.62.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.62.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.62.1", "@typescript-eslint/tsconfig-utils": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -342,6 +401,12 @@
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
@@ -362,16 +427,22 @@
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
@@ -394,14 +465,22 @@
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
@@ -410,10 +489,14 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
@@ -424,12 +507,16 @@
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"electron-to-chromium": ["electron-to-chromium@1.5.340", "", {}, "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
@@ -440,26 +527,58 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.6.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
@@ -470,10 +589,14 @@
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
"happy-dom": ["happy-dom@16.8.1", "", { "dependencies": { "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-n0QrmT9lD81rbpKsyhnlz3DgnMZlaOkJPpgi746doA+HvaMC79bdWkwjrNnGJRvDrWTI8iOcJiVTJ5CdT/AZRw=="],
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
@@ -498,6 +621,10 @@
"hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
"highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="],
@@ -508,6 +635,10 @@
"i18next": ["i18next@26.2.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -524,6 +655,8 @@
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="],
@@ -532,20 +665,34 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
@@ -662,12 +809,16 @@
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -676,10 +827,22 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="],
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
@@ -694,6 +857,8 @@
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -708,12 +873,18 @@
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
@@ -758,6 +929,10 @@
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
@@ -770,6 +945,12 @@
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -780,8 +961,12 @@
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
@@ -820,13 +1005,19 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"typescript-eslint": ["typescript-eslint@8.62.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.62.1", "@typescript-eslint/parser": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
@@ -846,6 +1037,8 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
@@ -874,14 +1067,36 @@
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -900,6 +1115,10 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -918,6 +1137,8 @@
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
@@ -932,6 +1153,10 @@
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
@@ -950,6 +1175,12 @@
"mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
}
}
+2
View File
@@ -23,6 +23,7 @@
"diff": "^9.0.0",
"i18next": "^26.0.6",
"lucide-react": "^0.469.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.4",
@@ -41,6 +42,7 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^24.0.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react-syntax-highlighter": "^15.5.13",
+540 -6
View File
@@ -6,7 +6,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, Sun } from "lucide-react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DeleteConfirm } from "@/components/DeleteConfirm";
import { RenameChatDialog } from "@/components/RenameChatDialog";
@@ -20,7 +20,9 @@ import { useSessions } from "@/hooks/useSessions";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { useSidebarState } from "@/hooks/useSidebarState";
import { useSkills } from "@/hooks/useSkills";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { logoFallbackUrls } from "@/lib/provider-brand";
import { cn } from "@/lib/utils";
import {
BootstrapAuthRequiredError,
@@ -38,6 +40,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
ChatSummary,
RuntimeSurface,
PairingRequestInfo,
SessionAutomationJob,
SettingsPayload,
WorkspaceScopePayload,
@@ -45,7 +48,12 @@ import type {
} from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import {
fetchPairingRequests,
fetchSettings,
fetchWorkspaces,
runPairingAction,
} from "@/lib/api";
import {
createRuntimeHost,
getHostApi,
@@ -70,11 +78,15 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const RESTART_ROUTE_KEY = "nanobot-webui.restartRoute";
const RESTART_ROUTE_TTL_MS = 5 * 60 * 1000;
const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
const PAIRING_POLL_INTERVAL_MS = 5_000;
const PAIRING_DISMISS_SNOOZE_MS = 30_000;
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
type ShellRoute = {
view: ShellView;
@@ -82,6 +94,106 @@ type ShellRoute = {
settingsSection: SettingsSectionKey;
};
type PairingChannelPresentation = {
label: string;
initials: string;
color: string;
logoUrl?: string;
};
const PAIRING_CHANNEL_PRESENTATION: Record<string, PairingChannelPresentation> = {
dingtalk: {
label: "DingTalk",
initials: "DT",
color: "#FF6A00",
logoUrl: "https://www.dingtalk.com/favicon.ico",
},
discord: {
label: "Discord",
initials: "DC",
color: "#5865F2",
logoUrl: "https://discord.com/favicon.ico",
},
email: {
label: "Email",
initials: "EM",
color: "#EA4335",
logoUrl: "https://gmail.com/favicon.ico",
},
feishu: {
label: "Feishu",
initials: "FS",
color: "#3370FF",
logoUrl: "https://www.feishu.cn/favicon.ico",
},
lark: {
label: "Lark",
initials: "LK",
color: "#3370FF",
logoUrl: "https://www.larksuite.com/favicon.ico",
},
matrix: {
label: "Matrix",
initials: "M",
color: "#111827",
logoUrl: "https://matrix.org/favicon.ico",
},
msteams: {
label: "Microsoft Teams",
initials: "MT",
color: "#6264A7",
logoUrl: "https://www.microsoft.com/favicon.ico",
},
napcat: {
label: "NapCat",
initials: "NC",
color: "#7C3AED",
logoUrl: "https://napneko.github.io/favicon.ico",
},
qq: {
label: "QQ",
initials: "QQ",
color: "#12B7F5",
logoUrl: "https://im.qq.com/favicon.ico",
},
signal: {
label: "Signal",
initials: "SG",
color: "#3A76F0",
logoUrl: "https://signal.org/favicon.ico",
},
slack: {
label: "Slack",
initials: "SL",
color: "#611F69",
logoUrl: "https://slack.com/favicon.ico",
},
telegram: {
label: "Telegram",
initials: "TG",
color: "#229ED9",
logoUrl: "https://telegram.org/favicon.ico",
},
wecom: {
label: "WeCom",
initials: "WC",
color: "#2F7DFF",
logoUrl: "https://work.weixin.qq.com/favicon.ico",
},
weixin: {
label: "WeChat",
initials: "WX",
color: "#07C160",
logoUrl: "https://weixin.qq.com/favicon.ico",
},
whatsapp: {
label: "WhatsApp",
initials: "WA",
color: "#25D366",
logoUrl: "https://www.whatsapp.com/favicon.ico",
},
};
const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"overview",
"appearance",
@@ -89,6 +201,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
"image",
"voice",
"browser",
"channels",
"apps",
"automations",
"skills",
@@ -109,11 +222,47 @@ function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
return "settings";
}
function fallbackRestartHash(hash: string): boolean {
return !hash || hash === "/" || hash === "/new";
}
function rememberRestartRoute(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(RESTART_ROUTE_KEY, window.location.hash || "#/new");
} catch {
// ignore storage errors
}
}
function maybeRestoreRestartHash(hash: string): string {
if (typeof window === "undefined" || !fallbackRestartHash(hash)) return hash;
try {
const startedAt = Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0");
const storedHash = window.localStorage.getItem(RESTART_ROUTE_KEY);
if (!startedAt || !storedHash || Date.now() - startedAt > RESTART_ROUTE_TTL_MS) {
window.localStorage.removeItem(RESTART_ROUTE_KEY);
return hash;
}
window.localStorage.removeItem(RESTART_ROUTE_KEY);
const nextHash = storedHash.startsWith("#") ? storedHash : `#${storedHash}`;
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${nextHash}`,
);
return nextHash.slice(1);
} catch {
return hash;
}
}
function readShellRoute(): ShellRoute {
if (typeof window === "undefined") return defaultShellRoute();
const hash = window.location.hash.startsWith("#")
const currentHash = window.location.hash.startsWith("#")
? window.location.hash.slice(1)
: window.location.hash;
const hash = maybeRestoreRestartHash(currentHash);
if (!hash || hash === "/" || hash === "/new") return defaultShellRoute();
const [path, query = ""] = hash.split("?", 2);
@@ -346,6 +495,286 @@ function HostChrome({
);
}
function PairingCodePopup({
requests,
total,
busyCode,
error,
onApprove,
onDismiss,
}: {
requests: PairingRequestInfo[];
total: number;
busyCode: string | null;
error: string | null;
onApprove: (code: string) => void;
onDismiss: (code: string) => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const normalizedCode = normalizePairingCode(value);
const matchedRequest = useMemo(
() => requests.find((request) => request.code === normalizedCode) ?? null,
[normalizedCode, requests],
);
const firstRequest = requests[0] ?? null;
const displayRequest = matchedRequest ?? firstRequest;
const expires = formatPairingExpiry(firstRequest?.expires_in_seconds);
const isCompleteCode = normalizedCode.length === 9;
const showNoMatch = isCompleteCode && !matchedRequest && !busyCode;
useEffect(() => {
if (!matchedRequest || busyCode) return;
onApprove(matchedRequest.code);
}, [busyCode, matchedRequest, onApprove]);
useEffect(() => {
if (!requests.length) setValue("");
}, [requests.length]);
if (!firstRequest) return null;
return (
<div
role="dialog"
aria-live="polite"
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
className={cn(
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
"border border-border/70 bg-popover/95 p-4 text-popover-foreground",
"shadow-[0_24px_70px_rgba(15,23,42,0.20)] backdrop-blur-xl",
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
)}
>
<div className="flex items-start gap-3">
<PairingChannelBadge channel={displayRequest.channel} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[15px] font-semibold tracking-[-0.01em]">
{t("app.pairing.title", { defaultValue: "Pair a chat user" })}
</p>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{t("app.pairing.description", {
defaultValue: "Enter the pairing code shown in the chat.",
})}
</p>
</div>
<button
type="button"
aria-label={t("common.close", { defaultValue: "Close" })}
onClick={() => onDismiss(firstRequest.code)}
className="rounded-full p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<label className="mt-4 block text-[12.5px] font-medium text-foreground">
{t("app.pairing.code", { defaultValue: "Pairing code" })}
</label>
<PairingCodeSlots
value={value}
disabled={Boolean(busyCode)}
matched={Boolean(matchedRequest)}
invalid={showNoMatch}
ariaLabel={t("app.pairing.code", { defaultValue: "Pairing code" })}
onChange={(next) => setValue(formatPairingCodeInput(next))}
/>
<div className="mt-3 flex items-center justify-between gap-3 text-[12.5px] text-muted-foreground">
<span>
{matchedRequest
? t("app.pairing.matched", {
defaultValue: "Matched {{channel}}. Connecting...",
channel: channelLabel(matchedRequest.channel),
})
: t("app.pairing.expiresInline", {
defaultValue: "Code expires {{expires}}.",
expires,
})}
</span>
{total > 1 ? (
<span className="shrink-0">
{t("app.pairing.queueCount", {
defaultValue: "{{count}} pending",
count: total,
})}
</span>
) : null}
</div>
{showNoMatch ? (
<p className="mt-2 text-[12px] leading-5 text-destructive">
{t("app.pairing.noMatch", {
defaultValue: "No pending request matches this code.",
})}
</p>
) : null}
{error ? (
<p className="mt-2 text-[12px] leading-5 text-destructive">{error}</p>
) : null}
</div>
</div>
</div>
);
}
function PairingChannelBadge({ channel }: { channel: string }) {
const key = pairingChannelKey(channel);
const presentation = PAIRING_CHANNEL_PRESENTATION[key];
const label = presentation?.label ?? channelLabel(channel);
const initials = presentation?.initials ?? label.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#10B981";
const logoUrls = useMemo(
() => logoFallbackUrls(presentation?.logoUrl),
[presentation?.logoUrl],
);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
return (
<div
className="mt-0.5 grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-2xl border bg-background shadow-sm"
style={{
borderColor: `${color}30`,
boxShadow: `inset 0 0 0 1px ${color}14, 0 1px 2px rgba(15,23,42,0.06)`,
}}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-6 w-6 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : presentation ? (
<span className="text-[11px] font-bold tracking-[-0.02em]" style={{ color }}>
{initials}
</span>
) : (
<ShieldCheck className="h-5 w-5" style={{ color }} />
)}
</div>
);
}
function PairingCodeSlots({
value,
disabled,
matched,
invalid,
ariaLabel,
onChange,
}: {
value: string;
disabled: boolean;
matched: boolean;
invalid: boolean;
ariaLabel: string;
onChange: (value: string) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [focused, setFocused] = useState(false);
const compact = compactPairingCode(value);
const activeIndex = Math.min(compact.length, 7);
const slots = Array.from({ length: 8 }, (_, index) => compact[index] ?? "");
const renderSlot = (char: string, index: number) => {
const highlighted = focused && index === activeIndex && !matched && !invalid;
return (
<div
key={index}
className={cn(
"grid h-10 w-7 place-items-center rounded-xl border",
"bg-background/80 font-mono text-[16px] font-semibold uppercase",
"text-foreground shadow-[0_1px_1px_rgba(15,23,42,0.04)] transition",
matched
? "border-emerald-500/45 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: invalid
? "border-destructive/55 bg-destructive/5 text-destructive"
: highlighted
? "border-foreground/30 bg-background text-foreground"
: char
? "border-border/80 bg-background text-foreground"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
{char || " "}
</div>
);
};
return (
<div
className={cn(
"relative mt-2 rounded-2xl border border-transparent p-1",
"transition duration-150",
focused && !disabled ? "border-ring/20 bg-muted/35" : "bg-transparent",
)}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef}
value={value}
aria-label={ariaLabel}
inputMode="text"
autoCapitalize="characters"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
maxLength={9}
disabled={disabled}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(event) => onChange(event.target.value)}
className="absolute inset-0 z-10 h-full w-full cursor-text opacity-0 disabled:cursor-default"
/>
<div className="pointer-events-none flex items-center gap-1.5">
{slots.slice(0, 4).map((char, index) => renderSlot(char, index))}
<div className="mx-0.5 h-px w-2.5 rounded-full bg-muted-foreground/35" />
{slots.slice(4).map((char, index) => renderSlot(char, index + 4))}
</div>
</div>
);
}
function compactPairingCode(raw: string): string {
return raw.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8).toUpperCase();
}
function formatPairingCodeInput(raw: string): string {
const compact = compactPairingCode(raw);
if (compact.length <= 4) return compact;
return `${compact.slice(0, 4)}-${compact.slice(4)}`;
}
function normalizePairingCode(raw: string): string {
return formatPairingCodeInput(raw);
}
function pairingChannelKey(channel: string): string {
const raw = channel.trim().toLowerCase();
if (!raw) return "";
return raw.split(/[.:]/)[0] ?? raw;
}
function channelLabel(channel: string): string {
const key = pairingChannelKey(channel);
return PAIRING_CHANNEL_PRESENTATION[key]?.label ?? channel;
}
function formatPairingExpiry(seconds: number | null | undefined): string {
if (seconds == null) return "soon";
if (seconds <= 0) return "expired";
if (seconds < 60) return `${seconds}s`;
return `${Math.ceil(seconds / 60)} min`;
}
export default function App() {
const { t } = useTranslation();
const [state, setState] = useState<BootState>({ status: "loading" });
@@ -510,9 +939,24 @@ export default function App() {
if (!hostApi?.restartEngine) {
throw new Error("native engine restart is unavailable");
}
await hostApi.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token;
rememberRestartRoute();
try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch {
// ignore storage errors
}
try {
await hostApi.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token;
} finally {
try {
window.localStorage.removeItem(RESTART_STARTED_KEY);
window.localStorage.removeItem(RESTART_ROUTE_KEY);
} catch {
// ignore storage errors
}
}
};
return (
@@ -585,6 +1029,12 @@ function Shell({
const restartSawDisconnectRef = useRef(false);
const [restartToast, setRestartToast] = useState<string | null>(null);
const [isRestarting, setIsRestarting] = useState(false);
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
const [pairingError, setPairingError] = useState<string | null>(null);
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
() => new Map(),
);
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
@@ -657,6 +1107,36 @@ function Shell({
writeSessionUpdateChatIds(updatedChatIds);
}, [updatedChatIds]);
const refreshPairingRequests = useCallback(async () => {
try {
const payload = await fetchPairingRequests(token);
const requests = Array.isArray(payload.requests) ? payload.requests : [];
setPairingRequests(requests);
setSnoozedPairingCodes((current) => {
if (current.size === 0) return current;
const activeCodes = new Set(requests.map((request) => request.code));
const now = Date.now();
const next = new Map(
Array.from(current).filter(
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
),
);
return next.size === current.size ? current : next;
});
} catch {
// Pairing is an opportunistic WebUI affordance. The slash command path
// remains available if this polling request fails.
}
}, [token]);
useEffect(() => {
void refreshPairingRequests();
const timer = window.setInterval(() => {
void refreshPairingRequests();
}, PAIRING_POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [refreshPairingRequests]);
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
return sessions.find((s) => s.key === activeKey) ?? null;
@@ -1237,6 +1717,7 @@ function Shell({
if (!chatId) return;
restartSawDisconnectRef.current = false;
setIsRestarting(true);
rememberRestartRoute();
try {
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
} catch {
@@ -1302,6 +1783,7 @@ function Shell({
if (!restartSawDisconnectRef.current && elapsedMs < 1500) return;
try {
window.localStorage.removeItem(RESTART_STARTED_KEY);
window.localStorage.removeItem(RESTART_ROUTE_KEY);
} catch {
// ignore storage errors
}
@@ -1357,6 +1839,50 @@ function Shell({
setPendingDelete({ key, label, automations });
}, [getSessionAutomations]);
const visiblePairingRequests = useMemo(
() => {
const now = Date.now();
return pairingRequests.filter((request) => {
const snoozedUntil = snoozedPairingCodes.get(request.code);
return !snoozedUntil || snoozedUntil <= now;
});
},
[pairingRequests, snoozedPairingCodes],
);
const onPairingAction = useCallback(
async (action: "approve" | "deny", code: string) => {
setPairingBusyCode(code);
setPairingError(null);
try {
const payload = await runPairingAction(token, action, code);
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
setSnoozedPairingCodes((current) => {
if (!current.has(code)) return current;
const next = new Map(current);
next.delete(code);
return next;
});
} catch (e) {
setPairingError((e as Error).message);
void refreshPairingRequests();
} finally {
setPairingBusyCode(null);
}
},
[refreshPairingRequests, token],
);
const onDismissPairingRequest = useCallback((code: string) => {
setSnoozedPairingCodes((current) => {
const snoozedUntil = Date.now() + PAIRING_DISMISS_SNOOZE_MS;
if (current.get(code) === snoozedUntil) return current;
const next = new Map(current);
next.set(code, snoozedUntil);
return next;
});
}, []);
const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
@@ -1653,6 +2179,14 @@ function Shell({
{restartToast}
</div>
) : null}
<PairingCodePopup
requests={visiblePairingRequests}
total={visiblePairingRequests.length}
busyCode={pairingBusyCode}
error={pairingError}
onApprove={(code) => void onPairingAction("approve", code)}
onDismiss={onDismissPairingRequest}
/>
</div>
</ThemeProvider>
);
+12 -11
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -137,16 +138,13 @@ export function CliAppMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [app.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
@@ -175,7 +173,10 @@ export function CliAppMentionToken({
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
decoding="async"
loading="lazy"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
) : null}
@@ -196,16 +197,13 @@ export function McpPresetMentionToken({
variant: "composer" | "message";
isHero?: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = preset.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const showLogo = Boolean(logoUrl);
const testIdPrefix = variant === "composer" ? "composer" : "message";
useEffect(() => setLogoIndex(0), [preset.logo_url]);
return (
<span
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
@@ -234,7 +232,10 @@ export function McpPresetMentionToken({
src={logoUrl ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setLogoIndex((index) => index + 1)}
decoding="async"
loading="lazy"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
) : null}
+8 -15
View File
@@ -1,10 +1,7 @@
import {
Children,
isValidElement,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
@@ -22,6 +19,7 @@ import {
isFilePatternReference,
isLikelyFilePath,
} from "@/components/FileReferenceChip";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { inferMediaKind } from "@/lib/media";
import { faviconUrls } from "@/lib/provider-brand";
import { remarkTexMath } from "@/lib/remark-tex-math";
@@ -304,7 +302,7 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
}
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
const { favicon, onFaviconError } = useFaviconFallback(link.host);
const { favicon, onFaviconError, onFaviconLoad } = useFaviconFallback(link.host);
const label = link.prefix
? `${link.prefix}${link.title}`
: link.title;
@@ -332,7 +330,9 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
src={favicon}
alt=""
className="h-3 w-3 rounded-[2px] object-contain"
decoding="async"
loading="lazy"
onLoad={onFaviconLoad}
onError={onFaviconError}
/>
) : (
@@ -348,19 +348,12 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
function useFaviconFallback(host: string) {
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
const [faviconIndex, setFaviconIndex] = useState(0);
useEffect(() => {
setFaviconIndex(0);
}, [host]);
const onFaviconError = useCallback(() => {
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
}, [faviconCandidates.length]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(faviconCandidates);
return {
favicon: faviconCandidates[faviconIndex] ?? null,
onFaviconError,
favicon: logoUrl ?? null,
onFaviconError: onLogoError,
onFaviconLoad: onLogoLoad,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
import { cn } from "@/lib/utils";
export function ToggleButton({
checked,
disabled,
onChange,
ariaLabel,
label,
}: {
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
ariaLabel?: string;
label: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel ?? label}
disabled={disabled}
onClick={() => {
if (!disabled) onChange(!checked);
}}
className={cn(
"relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full p-[2px]",
"transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
checked
? "bg-[#2997FF] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]"
: "bg-muted shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] hover:bg-muted/80",
disabled && "cursor-default opacity-60",
disabled && checked && "hover:bg-[#2997FF]",
disabled && !checked && "hover:bg-muted",
)}
>
<span
aria-hidden
className={cn(
"h-[18px] w-[18px] rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.18),0_2px_7px_rgba(0,0,0,0.11)]",
"transition-transform duration-200 ease-out",
checked ? "translate-x-[16px]" : "translate-x-0",
)}
/>
<span className="sr-only">{label}</span>
</button>
);
}
@@ -0,0 +1,134 @@
import { useMemo, type ReactNode } from "react";
import type { useTranslation } from "react-i18next";
import {
CHANNEL_PRESENTATION,
type ChannelSetupPresentation,
} from "@/components/settings/channels/catalog";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type { NanobotFeatureInfo } from "@/lib/types";
export type ChannelFilter = "all" | "on" | "off";
export function channelSetup(feature: NanobotFeatureInfo): ChannelSetupPresentation {
return CHANNEL_PRESENTATION[feature.name]?.setup ?? {
summary:
"Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.",
steps: [
`Open ~/.nanobot/config.json and find channels.${feature.name}.`,
"Add the credentials required by that platform, using the channel documentation as the source of truth.",
"Restart nanobot, then send a small test message from that platform.",
],
};
}
export function ChannelLogo({
feature,
showBrandLogos,
}: {
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#6B7280";
const Icon = presentation?.icon;
const logoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (showBrandLogos && logoUrl) {
return (
<span
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${color}22` }}
>
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-5.5 w-5.5 max-h-6 max-w-6 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
}
if (Icon) {
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background"
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
aria-hidden
>
<Icon className="h-5 w-5" strokeWidth={2.25} />
</span>
);
}
return (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background text-[11px] font-bold"
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
aria-hidden
>
{initials}
</span>
);
}
export function channelDisplayName(feature: NanobotFeatureInfo): string {
return CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name;
}
export function channelDescription(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
const fallback =
CHANNEL_PRESENTATION[feature.name]?.description ??
`Use nanobot from ${channelDisplayName(feature)}.`;
return t(`settings.channels.items.${feature.name}.description`, { defaultValue: fallback });
}
export function channelRequirements(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
const fallback =
CHANNEL_PRESENTATION[feature.name]?.requirements ??
"Channel credentials and gateway settings";
return t(`settings.channels.items.${feature.name}.requirements`, { defaultValue: fallback });
}
export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean {
if (filter === "on") return feature.enabled;
if (filter === "off") return !feature.enabled;
return true;
}
export function channelStatusLabel(
feature: NanobotFeatureInfo,
tx: (key: string, fallback: string) => string,
): string {
if (feature.enabled) return tx("settings.values.on", "On");
return tx("settings.values.off", "Off");
}
export function channelSearchText(feature: NanobotFeatureInfo): string {
return [
channelDisplayName(feature),
feature.display_name,
feature.name,
feature.status,
CHANNEL_PRESENTATION[feature.name]?.description,
CHANNEL_PRESENTATION[feature.name]?.requirements,
]
.join(" ")
.toLowerCase();
}
export function ChannelStatusBadge({ children }: { children: ReactNode }) {
return (
<span className="shrink-0 rounded-full bg-muted/75 px-2 py-0.5 text-[11px] font-medium leading-4 text-muted-foreground">
{children}
</span>
);
}
@@ -0,0 +1,334 @@
import { useCallback, useEffect, useRef, useState } from "react";
import QRCode from "qrcode";
import { Check, Loader2, Network, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
cancelChannelConnect,
pollChannelConnect,
startChannelConnect,
} from "@/lib/api";
import type {
ChannelConnectPayload,
NanobotFeaturesPayload,
} from "@/lib/types";
export type ChannelQrConnectLabels = {
qrAlt: string;
scanTitle: string;
scanDescription: string;
waiting: string;
connected: string;
stopped: string;
connecting: string;
scanAgain: string;
connect: string;
};
export function ChannelQrConnectFlow({
token,
channelName,
startOptions = {},
idleLabel,
connectRequestId,
labels,
onFeaturesUpdate,
}: {
token: string;
channelName: "feishu" | "weixin";
startOptions?: {
domain?: "feishu" | "lark";
instanceId?: string;
mode?: "replace" | "create";
force?: boolean;
};
idleLabel?: string;
connectRequestId?: number;
labels: ChannelQrConnectLabels;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [connect, setConnect] = useState<ChannelConnectPayload | null>(null);
const [qrDataUrl, setQrDataUrl] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [handledRequestId, setHandledRequestId] = useState(0);
const pollInFlight = useRef(false);
const startDomain = startOptions.domain;
const startInstanceId = startOptions.instanceId;
const startMode = startOptions.mode;
const startForce = startOptions.force;
const pending = connect?.status === "pending";
const succeeded = connect?.status === "succeeded";
const canStart = !pending && !busy;
useEffect(() => {
if (!connect?.qr_url) {
setQrDataUrl("");
return;
}
let cancelled = false;
void QRCode.toDataURL(connect.qr_url, {
width: 184,
margin: 1,
color: { dark: "#111827", light: "#ffffff" },
})
.then((url) => {
if (!cancelled) setQrDataUrl(url);
})
.catch(() => {
if (!cancelled) setQrDataUrl("");
});
return () => {
cancelled = true;
};
}, [connect?.qr_url]);
useEffect(() => {
if (!connect?.session_id || connect.status !== "pending") return;
let cancelled = false;
const poll = async () => {
if (pollInFlight.current) return;
pollInFlight.current = true;
try {
const payload = await pollChannelConnect(token, channelName, connect.session_id);
if (cancelled) return;
setConnect((current) => ({
...(current ?? payload),
...payload,
qr_url: payload.qr_url ?? current?.qr_url,
}));
if (payload.nanobot_features) {
onFeaturesUpdate(payload.nanobot_features);
}
if (payload.status !== "pending") {
setError(null);
}
} catch (err) {
if (!cancelled) setError((err as Error).message);
} finally {
pollInFlight.current = false;
}
};
const initial = window.setTimeout(() => void poll(), 900);
const interval = window.setInterval(
() => void poll(),
Math.max(2500, connect.interval_ms ?? 5000),
);
return () => {
cancelled = true;
window.clearTimeout(initial);
window.clearInterval(interval);
};
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, token]);
const start = useCallback(async (force = false) => {
setBusy(true);
setError(null);
try {
const payload = await startChannelConnect(token, channelName, {
domain: startDomain,
instanceId: startInstanceId,
mode: startMode,
force: force || startForce,
});
setConnect(payload);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}, [channelName, startDomain, startForce, startInstanceId, startMode, token]);
useEffect(() => {
if (!connectRequestId || connectRequestId === handledRequestId) return;
setHandledRequestId(connectRequestId);
void start();
}, [connectRequestId, handledRequestId, start]);
const cancel = async () => {
if (!connect?.session_id) {
setConnect(null);
return;
}
setBusy(true);
try {
const payload = await cancelChannelConnect(token, channelName, connect.session_id);
setConnect(payload);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<div className="mt-3 space-y-3">
{pending ? (
<div className="grid gap-4 rounded-[14px] border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
<div className="grid h-[196px] w-[196px] place-items-center rounded-[14px] border border-border/60 bg-background">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt={labels.qrAlt}
className="h-[184px] w-[184px]"
/>
) : (
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-hidden />
)}
</div>
<div className="flex min-w-0 flex-col justify-center">
<div className="text-[13px] font-semibold text-foreground">
{labels.scanTitle}
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
{labels.scanDescription}
</p>
<div className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
{labels.waiting}
</div>
<div className="mt-4 flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={() => void cancel()}
disabled={busy}
>
{tx("settings.actions.cancel", "Cancel")}
</Button>
</div>
</div>
</div>
) : null}
{succeeded ? (
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
<Check className="h-3.5 w-3.5" aria-hidden />
{connect.message ?? labels.connected}
</div>
) : null}
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
{connect.message || labels.stopped}
</div>
) : null}
{error ? (
<div className="rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void start(channelName === "weixin" && succeeded)}
disabled={!canStart}
>
{busy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : succeeded ? (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
) : (
<Network className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{pending
? labels.connecting
: succeeded
? labels.scanAgain
: idleLabel ?? labels.connect}
</Button>
</div>
</div>
);
}
export function FeishuConnectFlow({
token,
instanceId = "default",
mode = "replace",
idleLabel,
connectRequestId,
onFeaturesUpdate,
}: {
token: string;
instanceId?: string;
mode?: "replace" | "create";
idleLabel?: string;
connectRequestId?: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<ChannelQrConnectFlow
token={token}
channelName="feishu"
startOptions={{ domain: "feishu", instanceId, mode }}
idleLabel={idleLabel}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
labels={{
qrAlt: tx("settings.channels.feishuQrAlt", "Feishu connection QR code"),
scanTitle: tx("settings.channels.feishuScanTitle", "Scan with Feishu"),
scanDescription: tx(
"settings.channels.feishuScanDescription",
"Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
),
waiting: tx("settings.channels.feishuWaiting", "Waiting for authorization..."),
connected: tx("settings.channels.feishuConnected", "Feishu is connected."),
stopped: tx("settings.channels.feishuConnectStopped", "Connection stopped."),
connecting: tx("settings.channels.feishuConnecting", "Connecting..."),
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
connect: tx("settings.channels.connect", "Connect"),
}}
/>
);
}
export function WeixinConnectFlow({
token,
idleLabel,
connectRequestId,
onFeaturesUpdate,
}: {
token: string;
idleLabel?: string;
connectRequestId?: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<ChannelQrConnectFlow
token={token}
channelName="weixin"
idleLabel={idleLabel}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
labels={{
qrAlt: tx("settings.channels.weixinQrAlt", "WeChat login QR code"),
scanTitle: tx("settings.channels.weixinScanTitle", "Scan with WeChat"),
scanDescription: tx(
"settings.channels.weixinScanDescription",
"Use WeChat on your phone to scan this code. nanobot saves the account state locally after login.",
),
waiting: tx("settings.channels.weixinWaiting", "Waiting for WeChat scan..."),
connected: tx("settings.channels.weixinConnected", "WeChat is connected."),
stopped: tx("settings.channels.weixinConnectStopped", "WeChat login stopped."),
connecting: tx("settings.channels.weixinConnecting", "Connecting..."),
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
connect: tx("settings.channels.connect", "Connect"),
}}
/>
);
}
@@ -0,0 +1,552 @@
import { useEffect, useMemo, useState } from "react";
import {
Check,
ChevronDown,
ChevronRight,
Clipboard,
Loader2,
Plus,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { ToggleButton } from "@/components/settings/ToggleButton";
import {
type ChannelProviderPreset,
type ChannelSetupPresentation,
} from "@/components/settings/channels/catalog";
import {
CredentialForm,
channelValuesForSubmit,
defaultChannelFieldValues,
} from "@/components/settings/channels/CredentialForm";
import {
ChannelLogo,
ChannelStatusBadge,
channelDescription,
channelDisplayName,
channelRequirements,
channelSetup,
channelStatusLabel,
} from "@/components/settings/channels/ChannelIdentity";
import {
FeishuConnectFlow,
WeixinConnectFlow,
} from "@/components/settings/channels/ChannelQrConnectFlow";
import {
ChannelProviderPresets,
ChannelSetupActions,
ChannelSetupLinks,
ChannelSetupSteps,
ChannelValidationBadge,
ChannelValidationChecks,
ChannelValidationDetails,
} from "@/components/settings/channels/ChannelSetupParts";
import { FeishuAssistantsPanel } from "@/components/settings/channels/FeishuAssistantsPanel";
import { Button } from "@/components/ui/button";
import {
configureChannel,
validateChannel,
} from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
import type {
ChannelValidationPayload,
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function ChannelCatalogRow({
feature,
selected,
showBrandLogos,
onSelect,
}: {
feature: NanobotFeatureInfo;
selected: boolean;
showBrandLogos: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<button
type="button"
aria-label={t("settings.channels.selectChannel", {
name: channelDisplayName(feature),
defaultValue: "View {{name}} settings",
})}
aria-pressed={selected}
onClick={onSelect}
className={cn(
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
selected
? "border-border/55 bg-muted/35"
: "border-transparent hover:border-border/45 hover:bg-muted/25",
)}
>
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{channelDisplayName(feature)}
</h3>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">
{channelDescription(feature, t)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
<ChevronRight
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
selected && "translate-x-0.5 text-foreground",
)}
aria-hidden
/>
</div>
</button>
);
}
export function ChannelSetupPanel({
token,
feature,
actionKey,
chatAppsDocsUrl,
showBrandLogos,
onAction,
onFeaturesUpdate,
}: {
token: string;
feature: NanobotFeatureInfo;
actionKey: string | null;
chatAppsDocsUrl?: string;
showBrandLogos: boolean;
onAction: (action: "enable" | "disable", name: string) => void;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [connectRequestId, setConnectRequestId] = useState(0);
if (feature.name === "feishu") {
return (
<FeishuAssistantsPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
onFeaturesUpdate={onFeaturesUpdate}
/>
);
}
const enableBusy = actionKey === `enable:${feature.name}`;
const disableBusy = actionKey === `disable:${feature.name}`;
const missingSupport = feature.enabled && !feature.installed;
const requiredWebui = feature.name === "websocket";
const channelChecked = requiredWebui || feature.enabled;
const channelBusy = enableBusy || disableBusy;
const setup = channelSetup(feature);
const needsSetupBeforeEnable =
!channelChecked
&& feature.configured === false
&& !(feature.name === "weixin" && setup.mode === "connect");
const channelToggleDisabled =
requiredWebui
|| channelBusy
|| needsSetupBeforeEnable
|| (!feature.install_supported && !feature.installed && !feature.enabled);
const installSupportLabel = tx("settings.nanobotFeatures.installSupport", "Install support");
const toggleAriaLabel = t("settings.channels.toggleChannel", {
name: channelDisplayName(feature),
defaultValue: "{{name}} channel",
});
return (
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
{channelDisplayName(feature)}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{channelDescription(feature, t)}
</p>
{missingSupport && feature.install_supported ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={enableBusy}
onClick={() => onAction("enable", feature.name)}
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
>
{enableBusy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{installSupportLabel}
</Button>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 pt-1">
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
{channelBusy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
) : null}
<ToggleButton
checked={channelChecked}
disabled={channelToggleDisabled}
ariaLabel={toggleAriaLabel}
label={channelChecked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
onChange={(checked) => {
if (
feature.name === "weixin"
&& checked
&& !channelChecked
&& feature.configured === false
) {
setConnectRequestId((current) => current + 1);
return;
}
onAction(checked ? "enable" : "disable", feature.name);
}}
/>
</div>
</div>
<ChannelSetupSurface
token={token}
feature={feature}
setup={setup}
chatAppsDocsUrl={chatAppsDocsUrl}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
</aside>
);
}
function ChannelSetupSurface({
token,
feature,
setup,
chatAppsDocsUrl,
connectRequestId,
onFeaturesUpdate,
}: {
token: string;
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
chatAppsDocsUrl?: string;
connectRequestId: number;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [notice, setNotice] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<ChannelValidationPayload | null>(null);
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
const configValuesKey = JSON.stringify(feature.config_values ?? {});
const configuredFields = useMemo(
() => new Set(feature.configured_fields ?? []),
[feature.configured_fields],
);
const mode = setup.mode ?? "credentials";
const fields = setup.fields ?? [];
const requiredFields = fields.filter((field) => !field.optional);
const primaryFields = requiredFields.length ? requiredFields : fields.slice(0, 1);
const optionalFields = fields.filter((field) => field.optional);
const manualFields = setup.manualFields ?? [];
const advancedFields = mode === "connect" ? manualFields : optionalFields;
const editableFields = mode === "credentials" ? fields : mode === "connect" ? manualFields : [];
const hasAdvanced = advancedFields.length > 0;
const requirements = channelRequirements(feature, t);
const summary = t(`settings.channels.items.${feature.name}.setup.summary`, {
defaultValue:
setup.summary ??
tx(
"settings.channels.setupSummary",
"Enable only turns on nanobot support. Add the platform credentials, then restart nanobot.",
),
});
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
defaultChannelFieldValues(editableFields, feature.config_values),
);
useEffect(() => {
setNotice(null);
setVisibleSecrets({});
setSaving(false);
setValidating(false);
setValidation(null);
setTouchedFields(new Set());
setFieldValues(defaultChannelFieldValues(editableFields, feature.config_values));
}, [configValuesKey, feature.name]);
const toggleSecret = (key: string) => {
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
};
const setFieldValue = (key: string, value: string) => {
setFieldValues((current) => ({ ...current, [key]: value }));
setTouchedFields((current) => new Set(current).add(key));
};
const applyPreset = (preset: ChannelProviderPreset) => {
setFieldValues((current) => ({ ...current, ...preset.values }));
setTouchedFields((current) => {
const next = new Set(current);
for (const key of Object.keys(preset.values)) next.add(key);
return next;
});
};
const copyCommand = () => {
if (!setup.command) return;
void copyTextToClipboard(setup.command).then((ok) => {
setNotice(
ok
? tx("settings.channels.commandCopied", "Command copied.")
: tx("settings.channels.commandCopyFailed", "Could not copy command."),
);
});
};
const saveCredentialSettings = async () => {
setSaving(true);
setValidating(true);
setNotice(null);
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
try {
const validationPayload = await validateChannel(token, feature.name, values);
setValidation(validationPayload);
if (!validationPayload.can_enable) {
setNotice(
validationPayload.message
?? tx("settings.channels.validationFailed", "Check the required setup before enabling."),
);
return;
}
const payload = await configureChannel(
token,
feature.name,
values,
{ enable: true },
);
if (payload.nanobot_features) {
onFeaturesUpdate(payload.nanobot_features);
}
setNotice(tx("settings.channels.checkedAndEnabled", "Checked and enabled."));
} catch (err) {
setNotice((err as Error).message);
} finally {
setSaving(false);
setValidating(false);
}
};
const checkCurrentSettings = async () => {
setValidating(true);
setNotice(null);
try {
const payload = await validateChannel(
token,
feature.name,
channelValuesForSubmit(fields, fieldValues, touchedFields),
);
setValidation(payload);
if (payload.message) setNotice(payload.message);
} catch (err) {
setNotice((err as Error).message);
} finally {
setValidating(false);
}
};
const primaryActionLabel = feature.enabled
? tx("settings.channels.checkConnection", "Check connection")
: tx("settings.channels.checkAndEnable", "Check and enable");
return (
<div className="mt-5 overflow-hidden rounded-[16px] border border-border/70 bg-background shadow-none">
<section className="px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[13px] font-semibold text-foreground">
{tx("settings.channels.requiredSetup", "Required setup")}
</div>
<div className="flex max-w-full flex-wrap justify-end gap-2">
{mode !== "webui" ? (
<ChannelValidationBadge
validation={validation}
validating={validating}
feature={feature}
/>
) : null}
{mode === "webui" ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-1 text-[11.5px] font-medium text-emerald-700 dark:text-emerald-200">
<Check className="h-3.5 w-3.5" aria-hidden />
{tx("settings.channels.managedByWebui", "Managed by WebUI")}
</span>
) : null}
</div>
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">{requirements}</p>
<p className="mt-3 text-[12.5px] leading-5 text-muted-foreground">{summary}</p>
<ChannelValidationDetails validation={validation} />
<ChannelSetupLinks feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} />
<ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} />
{mode === "connect" && feature.name === "feishu" ? (
<FeishuConnectFlow
token={token}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
) : mode === "connect" && feature.name === "weixin" ? (
<WeixinConnectFlow
token={token}
idleLabel={t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
})}
connectRequestId={connectRequestId}
onFeaturesUpdate={onFeaturesUpdate}
/>
) : mode === "connect" ? (
<>
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() =>
setNotice(
tx(
"settings.channels.connectPreview",
"The in-browser connect flow is next. For now, run the command below.",
),
)
}
>
{t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
})}
</Button>
{setup.command ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={copyCommand}
>
<Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.channels.copyCommand", "Copy command")}
</Button>
) : null}
</div>
{setup.command ? (
<code className="mt-3 block rounded-[10px] border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
{setup.command}
</code>
) : null}
</>
) : mode === "credentials" ? (
<>
{setup.presets?.length ? (
<ChannelProviderPresets
featureName={feature.name}
presets={setup.presets}
onApply={applyPreset}
/>
) : null}
{primaryFields.length ? (
<CredentialForm
fields={primaryFields}
values={fieldValues}
configuredFields={configuredFields}
visibleSecrets={visibleSecrets}
onChange={setFieldValue}
onToggleSecret={toggleSecret}
/>
) : null}
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void saveCredentialSettings()}
disabled={saving}
>
{saving || validating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{primaryActionLabel}
</Button>
{feature.configured || validation ? (
<Button
type="button"
size="sm"
variant="ghost"
className="h-8 rounded-full px-3 text-[12px] font-semibold"
onClick={() => void checkCurrentSettings()}
disabled={saving || validating}
>
{tx("settings.channels.checkOnly", "Check only")}
</Button>
) : null}
</div>
</>
) : null}
</section>
{notice ? (
<div
role="status"
className="border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground"
>
{notice}
</div>
) : null}
{setup.steps.length ? (
<ChannelSetupSteps featureName={feature.name} steps={setup.steps} tryIt={setup.tryIt} />
) : null}
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
{hasAdvanced ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open:rotate-180" aria-hidden />
</span>
</summary>
{advancedFields.length ? (
<div className="mt-3">
<CredentialForm
fields={advancedFields}
values={fieldValues}
configuredFields={configuredFields}
visibleSecrets={visibleSecrets}
onChange={setFieldValue}
onToggleSecret={toggleSecret}
compact
/>
</div>
) : null}
</details>
) : null}
</div>
);
}
@@ -0,0 +1,393 @@
import { useMemo, useState, type ReactNode } from "react";
import { Clipboard, ExternalLink, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
CHANNEL_PRESENTATION,
docsUrlWithBase,
type ChannelProviderPreset,
type ChannelSetupPresentation,
} from "@/components/settings/channels/catalog";
import {
channelValidationCheckIcon,
channelValidationCheckIconClass,
channelValidationStatusClass,
channelValidationStatusIcon,
channelValidationStatusLabel,
} from "@/components/settings/channels/CredentialForm";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { copyTextToClipboard } from "@/lib/clipboard";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type {
ChannelValidationPayload,
NanobotFeatureInfo,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function ChannelGuideLink({
feature,
setup,
chatAppsDocsUrl,
compact = false,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
chatAppsDocsUrl?: string;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const presentation = CHANNEL_PRESENTATION[feature.name];
const logoUrls = useMemo(
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
[presentation?.logoUrl, setup.docsLogoUrl],
);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const Icon = presentation?.icon;
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#6B7280";
const docsUrl = docsUrlWithBase(setup.docsUrl, chatAppsDocsUrl);
if (!docsUrl) return null;
return (
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className={cn(
"inline-flex max-w-full items-center gap-2 border border-border/65 bg-background/90 font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45",
compact
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
)}
>
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background font-bold",
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
)}
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className={cn("object-contain", compact ? "h-3.5 w-3.5" : "h-4 w-4")}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : Icon ? (
<Icon className={compact ? "h-3 w-3" : "h-3.5 w-3.5"} strokeWidth={2.25} />
) : (
initials
)}
</span>
<span className="truncate">
{t(`settings.channels.items.${feature.name}.setup.docsLabel`, {
defaultValue: setup.docsLabel ?? tx("settings.channels.officialGuide", "Official guide"),
})}
</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</a>
);
}
export function ChannelSetupLinks({
feature,
setup,
chatAppsDocsUrl,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
chatAppsDocsUrl?: string;
}) {
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<ChannelOfficialLink feature={feature} setup={setup} />
<ChannelGuideLink feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} compact />
</div>
);
}
export function ChannelOfficialLink({
feature,
setup,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const logoUrls = useMemo(
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
[presentation?.logoUrl, setup.docsLogoUrl],
);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const Icon = presentation?.icon;
const color = presentation?.color ?? "#6B7280";
const label = setup.officialLabel;
if (!setup.officialUrl || !label) return null;
return (
<a
href={setup.officialUrl}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full border border-border/65 bg-background/90 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45"
>
<span
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background"
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
aria-hidden
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-3.5 w-3.5 object-contain"
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : Icon ? (
<Icon className="h-3 w-3" strokeWidth={2.25} />
) : null}
</span>
<span className="truncate">{label}</span>
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
</a>
);
}
export function ChannelSetupActions({
feature,
setup,
onNotice,
}: {
feature: NanobotFeatureInfo;
setup: ChannelSetupPresentation;
onNotice: (message: string | null) => void;
}) {
const { t } = useTranslation();
if (!setup.actions?.length) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
{setup.actions.map((action) => (
<Button
key={action.id}
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => {
if (action.copyText) {
void copyTextToClipboard(action.copyText).then((ok) =>
onNotice(
ok
? t("settings.channels.helperCopied", {
name: action.label,
defaultValue: "{{name}} copied.",
})
: t("settings.channels.helperCopyFailed", {
name: action.label,
defaultValue: "Could not copy {{name}}.",
}),
),
);
}
}}
>
{action.copyText ? <Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
{action.label}
</Button>
))}
<span className="sr-only">
{CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name}
</span>
</div>
);
}
export function ChannelProviderPresets({
featureName,
presets,
onApply,
}: {
featureName: string;
presets: ChannelProviderPreset[];
onApply: (preset: ChannelProviderPreset) => void;
}) {
const { t } = useTranslation();
const [selected, setSelected] = useState("");
if (!presets.length) return null;
return (
<div className="mt-3">
<div className="mb-1 text-[11px] font-medium text-foreground/85">
{t(`settings.channels.items.${featureName}.providerPreset`, {
defaultValue: "Provider",
})}
</div>
<div
role="radiogroup"
aria-label={t(`settings.channels.items.${featureName}.providerPreset`, {
defaultValue: "Provider",
})}
className="grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
>
{presets.map((preset) => (
<button
key={preset.id}
type="button"
role="radio"
aria-checked={selected === preset.id}
onClick={() => {
setSelected(preset.id);
onApply(preset);
}}
className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selected === preset.id
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
)}
>
{preset.label}
</button>
))}
</div>
</div>
);
}
export function ChannelValidationBadge({
validation,
validating,
feature,
}: {
validation: ChannelValidationPayload | null;
validating: boolean;
feature: NanobotFeatureInfo;
}) {
const { t } = useTranslation();
const status = validation?.status ?? (feature.configured ? "configured" : "needs_setup");
const label = validating
? t("settings.channels.checking", { defaultValue: "Checking..." })
: channelValidationStatusLabel(status, t);
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
channelValidationStatusClass(status),
)}
>
{validating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
channelValidationStatusIcon(status)
)}
{label}
</span>
);
}
export function ChannelValidationDetails({ validation }: { validation: ChannelValidationPayload | null }) {
const message = validation?.message;
if (!validation?.identity?.name && !message) return null;
return (
<div className="mt-2 truncate text-[11.5px] text-muted-foreground">
{validation?.identity?.name
? validation.identity.workspace
? `${validation.identity.name} · ${validation.identity.workspace}`
: validation.identity.name
: message}
</div>
);
}
export function ChannelValidationChecks({ validation }: { validation: ChannelValidationPayload }) {
if (!validation.checks.length) return null;
return (
<div className="border-t border-border/60 px-4 py-4">
<div className="mb-2 text-[12px] font-semibold text-foreground">Connection checks</div>
<div className="space-y-2">
{validation.checks.slice(0, 6).map((check) => (
<div key={check.id} className="flex gap-2 text-[12px] leading-5">
<span className={cn("mt-0.5", channelValidationCheckIconClass(check.status))}>
{channelValidationCheckIcon(check.status)}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-foreground/85">{check.label}</div>
{check.message ? (
<div className="text-muted-foreground">{check.message}</div>
) : null}
{check.action_url ? (
<a
href={check.action_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-foreground underline decoration-border underline-offset-4"
>
Open
<ExternalLink className="h-3 w-3" aria-hidden />
</a>
) : null}
</div>
</div>
))}
</div>
</div>
);
}
export function ChannelSetupSteps({
featureName,
steps,
action,
tryIt,
}: {
featureName: string;
steps: string[];
action?: ReactNode;
tryIt?: string;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className="border-t border-border/60 px-4 py-4 text-[12.5px] leading-5 text-muted-foreground">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[12px] font-semibold text-foreground">
{tx("settings.channels.setupSteps", "Next steps")}
</div>
{action}
</div>
<ol className="space-y-1.5">
{steps.map((step, index) => (
<li key={step} className="flex gap-2">
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-background text-[10px] font-semibold text-muted-foreground shadow-sm">
{index + 1}
</span>
<span>
{t(`settings.channels.items.${featureName}.setup.steps.${index}`, {
defaultValue: step,
})}
</span>
</li>
))}
</ol>
{tryIt ? (
<div className="mt-3 rounded-[12px] border border-border/55 bg-background px-3 py-2 text-[12px] text-muted-foreground">
<span className="font-medium text-foreground">
{tx("settings.channels.tryIt", "Try it")}
</span>
<span className="ml-2">
{t(`settings.channels.items.${featureName}.setup.tryIt`, { defaultValue: tryIt })}
</span>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,231 @@
import type { ReactNode } from "react";
import { Check, CircleAlert, Eye, EyeOff, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
import { cn } from "@/lib/utils";
export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
}
export function defaultChannelFieldValues(
fields: ChannelConfigField[],
configValues: Record<string, string> | undefined = undefined,
): Record<string, string> {
return Object.fromEntries(
fields.map((field) => [
field.key,
configValues?.[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "",
]),
);
}
export function channelValuesForSave(
fields: ChannelConfigField[],
values: Record<string, string>,
): Record<string, string> {
const payload: Record<string, string> = {};
for (const field of fields) {
const value = channelFieldValue(field, values);
if (field.secret && !value.trim()) continue;
payload[field.key] = value;
}
return payload;
}
export function channelValuesForSubmit(
fields: ChannelConfigField[],
values: Record<string, string>,
touchedFields: Set<string>,
): Record<string, string> {
const payload: Record<string, string> = {};
for (const field of fields) {
const touched = touchedFields.has(field.key);
const value = channelFieldValue(field, values);
if (field.secret && !value.trim()) continue;
if (!touched && !value.trim()) continue;
if (!touched && field.options?.length) continue;
payload[field.key] = value;
}
return payload;
}
export function channelValidationStatusLabel(
status: string,
t: ReturnType<typeof useTranslation>["t"],
): string {
const labels: Record<string, string> = {
connected: "Connected",
configured: "Configured manually",
needs_setup: "Needs setup",
invalid: "Invalid",
unsupported: "Manual setup",
};
return t(`settings.channels.validation.${status}`, {
defaultValue: labels[status] ?? "Checked",
});
}
export function channelValidationStatusClass(status: string): string {
if (status === "connected") {
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200";
}
if (status === "configured") {
return "bg-blue-500/10 text-blue-700 dark:text-blue-200";
}
if (status === "invalid") {
return "bg-destructive/10 text-destructive";
}
return "bg-muted text-muted-foreground";
}
export function channelValidationStatusIcon(status: string): ReactNode {
if (status === "connected" || status === "configured") {
return <Check className="h-3.5 w-3.5" aria-hidden />;
}
if (status === "invalid") {
return <X className="h-3.5 w-3.5" aria-hidden />;
}
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
}
export function channelValidationCheckIcon(status: string): ReactNode {
if (status === "pass") return <Check className="h-3.5 w-3.5" aria-hidden />;
if (status === "fail") return <X className="h-3.5 w-3.5" aria-hidden />;
if (status === "warn") return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
}
export function channelValidationCheckIconClass(status: string): string {
if (status === "pass") return "text-emerald-600";
if (status === "fail") return "text-destructive";
if (status === "warn") return "text-amber-600";
return "text-muted-foreground";
}
export function CredentialForm({
fields,
values,
configuredFields,
visibleSecrets,
onChange,
onToggleSecret,
compact = false,
}: {
fields: ChannelConfigField[];
values: Record<string, string>;
configuredFields?: Set<string>;
visibleSecrets: Record<string, boolean>;
onChange: (key: string, value: string) => void;
onToggleSecret: (key: string) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
return (
<div className={cn(compact ? "space-y-2.5" : "mt-3 space-y-2.5")}>
{fields.map((field) => {
const visible = Boolean(visibleSecrets[field.key]);
const value = values[field.key] ?? "";
const savedSecret = Boolean(field.secret && configuredFields?.has(field.key) && !value.trim());
const showSecretToggle = Boolean(field.secret && value.trim());
const inputType = field.secret && !visible ? "password" : field.inputType ?? "text";
const selectedOption = channelFieldValue(field, values);
const header = (
<span className="flex items-center justify-between gap-2 text-[11px] font-medium text-foreground/85">
<span>{field.label}</span>
{savedSecret ? (
<span className="font-normal text-muted-foreground">
{tx("settings.channels.savedSecret", "Saved")}
</span>
) : field.optional && !compact ? (
<span className="font-normal text-muted-foreground">
{tx("settings.channels.optional", "Optional")}
</span>
) : null}
</span>
);
const help = field.help ? (
<span className="mt-1 block text-[11px] leading-4 text-muted-foreground">
{field.help}
</span>
) : null;
if (field.options?.length) {
return (
<div key={field.key} className="block">
{header}
<span
role="radiogroup"
aria-label={field.label}
className="mt-1 grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
>
{field.options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selectedOption === option.value}
onClick={() => onChange(field.key, option.value)}
className={cn(
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
selectedOption === option.value
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
)}
>
{option.label}
</button>
))}
</span>
{help}
</div>
);
}
return (
<label key={field.key} className="block">
{header}
<span className="relative mt-1 block">
<Input
aria-label={field.label}
type={inputType}
inputMode={field.inputType === "number" ? "numeric" : undefined}
placeholder={
savedSecret
? tx("settings.channels.savedSecretPlaceholder", "Saved secret")
: field.placeholder
}
value={values[field.key] ?? ""}
onChange={(event) => onChange(field.key, event.target.value)}
className={cn(
"h-9 rounded-[10px] border-border/60 bg-muted/35 text-[13px]",
showSecretToggle && "pr-9",
)}
/>
{showSecretToggle ? (
<button
type="button"
aria-label={
visible
? tx("settings.channels.hideSecret", "Hide secret")
: tx("settings.channels.showSecret", "Show secret")
}
onClick={() => onToggleSecret(field.key)}
className="absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
>
{visible ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</button>
) : null}
</span>
{help}
</label>
);
})}
</div>
);
}
@@ -0,0 +1,478 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Loader2, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ToggleButton } from "@/components/settings/ToggleButton";
import {
CHANNEL_PRESENTATION,
type ChannelConfigField,
} from "@/components/settings/channels/catalog";
import {
CredentialForm,
channelValidationStatusClass,
channelValidationStatusIcon,
channelValuesForSave,
defaultChannelFieldValues,
} from "@/components/settings/channels/CredentialForm";
import {
ChannelLogo,
ChannelStatusBadge,
channelDisplayName,
channelSetup,
channelStatusLabel,
} from "@/components/settings/channels/ChannelIdentity";
import { FeishuConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
import {
ChannelGuideLink,
ChannelSetupSteps,
} from "@/components/settings/channels/ChannelSetupParts";
import { Button } from "@/components/ui/button";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import {
configureChannel,
disableNanobotFeature,
enableNanobotFeature,
} from "@/lib/api";
import { logoFallbackUrls } from "@/lib/provider-brand";
import type {
NanobotChannelInstanceInfo,
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { cn } from "@/lib/utils";
export function FeishuAssistantsPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
onFeaturesUpdate,
}: {
token: string;
feature: NanobotFeatureInfo;
showBrandLogos: boolean;
chatAppsDocsUrl?: string;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const instances = feishuFeatureInstances(feature);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const selected = selectedId ? instances.find((instance) => instance.id === selectedId) : undefined;
const setup = channelSetup(feature);
const manualFields = setup.manualFields ?? [];
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
feishuInstanceFieldValues(manualFields, selected),
);
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
const [savingFields, setSavingFields] = useState(false);
const connectedAssistantCount = instances.filter((instance) => instance.configured).length;
useEffect(() => {
if (selectedId && !instances.some((instance) => instance.id === selectedId)) {
setSelectedId(null);
}
}, [instances, selectedId]);
useEffect(() => {
setFieldValues(feishuInstanceFieldValues(manualFields, selected));
setVisibleSecrets({});
}, [
manualFields,
selected?.allow_from,
selected?.app_id,
selected?.domain,
selected?.group_policy,
selected?.id,
]);
const toggleInstance = async (instance: NanobotChannelInstanceInfo, checked: boolean) => {
setBusyInstanceId(instance.id);
setNotice(null);
try {
const payload = checked
? await enableNanobotFeature(token, "feishu", { instanceId: instance.id })
: await disableNanobotFeature(token, "feishu", { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
} finally {
setBusyInstanceId(null);
}
};
const reconnectInstance = async (instance: NanobotChannelInstanceInfo) => {
setBusyInstanceId(instance.id);
setNotice(null);
try {
const payload = await enableNanobotFeature(token, "feishu", { instanceId: instance.id });
onFeaturesUpdate(payload);
} catch (err) {
setNotice((err as Error).message);
} finally {
setBusyInstanceId(null);
}
};
const saveSelectedInstanceSettings = async () => {
if (!selected) return;
setSavingFields(true);
setNotice(null);
try {
const payload = await configureChannel(
token,
"feishu",
channelValuesForSave(manualFields, fieldValues),
{ enable: selected.enabled, instanceId: selected.id },
);
if (payload.nanobot_features) {
onFeaturesUpdate(payload.nanobot_features);
}
setNotice(tx("settings.channels.savedSettings", "Saved settings."));
} catch (err) {
setNotice((err as Error).message);
} finally {
setSavingFields(false);
}
};
return (
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
{channelDisplayName(feature)}
</h3>
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
{feishuAssistantCountLabel(connectedAssistantCount, tx)}
</p>
</div>
</div>
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
</div>
<div className="mt-5 space-y-3">
{instances.map((instance) => {
const expanded = selected?.id === instance.id;
return (
<article
key={instance.id}
className={cn(
"overflow-hidden rounded-[18px] border transition-colors",
expanded
? "border-border/75 bg-card/95 shadow-sm"
: "border-border/55 bg-background hover:border-border/75 hover:bg-muted/15",
)}
>
<div className="flex items-center gap-3 px-3 py-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() =>
setSelectedId((current) => (current === instance.id ? null : instance.id))
}
aria-expanded={expanded}
>
<FeishuAssistantAvatar
feature={feature}
instance={instance}
showBrandLogos={showBrandLogos}
size="lg"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
{feishuInstanceDisplayName(instance)}
</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-180",
)}
aria-hidden
/>
</button>
<div className="flex shrink-0 items-center gap-2">
{busyInstanceId === instance.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
) : null}
<ToggleButton
checked={instance.enabled}
disabled={busyInstanceId === instance.id || !instance.configured}
ariaLabel={t("settings.channels.toggleFeishuAssistant", {
name: feishuInstanceDisplayName(instance),
defaultValue: "{{name}} assistant",
})}
label={instance.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
onChange={(checked) => void toggleInstance(instance, checked)}
/>
</div>
</div>
{expanded ? (
<div className="border-t border-border/60">
<section className="px-4 py-4">
<div className="mb-3 flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 truncate font-mono text-[11.5px] leading-6 text-muted-foreground">
{maskFeishuAppId(instance.app_id) || tx("settings.channels.noAppId", "No App ID")}
</p>
<FeishuAssistantConnectionBadge instance={instance} />
</div>
{instance.configured ? (
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void reconnectInstance(instance)}
disabled={busyInstanceId === instance.id || !instance.enabled}
>
{busyInstanceId === instance.id ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{tx("settings.channels.reconnectAssistant", "Reconnect")}
</Button>
</div>
) : (
<FeishuConnectFlow
key={`connect-${instance.id}`}
token={token}
instanceId={instance.id}
mode="replace"
idleLabel={tx("settings.channels.connect", "Connect")}
onFeaturesUpdate={onFeaturesUpdate}
/>
)}
</section>
<ChannelSetupSteps
featureName={feature.name}
steps={setup.steps}
action={
<ChannelGuideLink
feature={feature}
setup={setup}
chatAppsDocsUrl={chatAppsDocsUrl}
compact
/>
}
/>
{manualFields.length ? (
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
<span className="inline-flex items-center gap-1.5">
{tx("settings.channels.advanced", "Advanced")}
<ChevronDown
className="h-3.5 w-3.5 transition-transform group-open:rotate-180"
aria-hidden
/>
</span>
</summary>
<div className="mt-3">
<CredentialForm
fields={manualFields}
values={fieldValues}
visibleSecrets={visibleSecrets}
onChange={(key, value) =>
setFieldValues((current) => ({ ...current, [key]: value }))
}
onToggleSecret={(key) =>
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }))
}
compact
/>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void saveSelectedInstanceSettings()}
disabled={savingFields}
>
{savingFields ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : null}
{tx("settings.channels.saveSettings", "Save settings")}
</Button>
</div>
</div>
</details>
) : null}
</div>
) : null}
</article>
);
})}
</div>
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
<div className="text-[13px] font-semibold text-foreground">
{tx("settings.channels.createFeishuAssistant", "Create another assistant")}
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
{tx(
"settings.channels.createFeishuAssistantHint",
"Create a separate Feishu bot for another team, space, or workflow.",
)}
</p>
<FeishuConnectFlow
key="create-feishu-assistant"
token={token}
instanceId="default"
mode="create"
idleLabel={tx("settings.channels.createAssistant", "Create assistant")}
onFeaturesUpdate={onFeaturesUpdate}
/>
</div>
{notice ? (
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{notice}
</div>
) : null}
</aside>
);
}
function feishuFeatureInstances(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo[] {
if (feature.instances?.length) return feature.instances;
return [{
id: "default",
name: "nanobot",
domain: "feishu",
enabled: feature.enabled,
configured: Boolean(feature.configured),
app_id: "",
}];
}
function feishuAssistantCountLabel(
count: number,
tx: (key: string, fallback: string) => string,
): string {
if (count === 0) {
return tx("settings.channels.noFeishuAssistants", "No assistant connected");
}
if (count === 1) {
return tx("settings.channels.oneFeishuAssistant", "1 assistant connected");
}
return tx("settings.channels.manyFeishuAssistants", `${count} assistants connected`);
}
function feishuInstanceDisplayName(instance: NanobotChannelInstanceInfo): string {
const displayName = instance.display_name?.trim();
if (displayName) return displayName;
const localName = instance.name?.trim();
if (localName) return localName;
return instance.id === "default" ? "nanobot" : "nanobot";
}
function FeishuAssistantConnectionBadge({ instance }: { instance: NanobotChannelInstanceInfo }) {
const { t } = useTranslation();
const status = instance.configured ? "connected" : "needs_setup";
const label = instance.configured
? t("settings.channels.feishuConfigured", { defaultValue: "Connected" })
: t("settings.channels.feishuNotConfigured", { defaultValue: "Needs authorization" });
return (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
channelValidationStatusClass(status),
)}
>
{channelValidationStatusIcon(status)}
{label}
</span>
);
}
function FeishuAssistantAvatar({
feature,
instance,
showBrandLogos,
size,
}: {
feature: NanobotFeatureInfo;
instance: NanobotChannelInstanceInfo;
showBrandLogos: boolean;
size: "sm" | "lg";
}) {
const presentation = CHANNEL_PRESENTATION[feature.name];
const [avatarFailed, setAvatarFailed] = useState(false);
const fallbackLogoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(fallbackLogoUrls);
const remoteAvatarUrl = !avatarFailed ? instance.avatar_url?.trim() : "";
const imageUrl = remoteAvatarUrl || (showBrandLogos ? logoUrl : "");
const Icon = presentation?.icon;
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
const color = presentation?.color ?? "#3370FF";
const frameClass = size === "lg" ? "h-11 w-11" : "h-9 w-9";
const fallbackImageClass = size === "lg" ? "h-6 w-6" : "h-5 w-5";
const iconClass = size === "lg" ? "h-5 w-5" : "h-4 w-4";
useEffect(() => {
setAvatarFailed(false);
}, [instance.avatar_url]);
return (
<span
className={cn(
"grid shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background text-[10px] font-bold",
frameClass,
)}
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
aria-hidden
>
{remoteAvatarUrl ? (
<img
src={remoteAvatarUrl}
alt=""
decoding="async"
loading="lazy"
className="h-full w-full object-cover"
onError={() => setAvatarFailed(true)}
/>
) : imageUrl ? (
<img
src={imageUrl}
alt=""
decoding="async"
loading="lazy"
className={cn("object-contain", fallbackImageClass)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : Icon ? (
<Icon className={iconClass} strokeWidth={2.25} />
) : (
initials
)}
</span>
);
}
function maskFeishuAppId(appId: string | undefined): string {
if (!appId) return "";
if (appId.length <= 10) return appId;
return `${appId.slice(0, 7)}...${appId.slice(-4)}`;
}
function feishuInstanceFieldValues(
fields: ChannelConfigField[],
instance: NanobotChannelInstanceInfo | undefined,
): Record<string, string> {
const values = defaultChannelFieldValues(fields);
if (!instance) return values;
values["channels.feishu.appId"] = instance.app_id ?? "";
values["channels.feishu.appSecret"] = "";
values["channels.feishu.domain"] = instance.domain ?? values["channels.feishu.domain"] ?? "feishu";
values["channels.feishu.groupPolicy"] =
instance.group_policy ?? values["channels.feishu.groupPolicy"] ?? "mention";
values["channels.feishu.allowFrom"] = (instance.allow_from ?? []).join(", ");
return values;
}
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,7 @@ import {
type ActivityEvidence,
} from "@/lib/activity-timeline";
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { hasRenderableFileDiff } from "@/lib/file-diff";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
@@ -943,10 +944,12 @@ function TraceIconMark({
fallbackIcon: LucideIcon;
active: boolean;
}) {
const [faviconIndex, setFaviconIndex] = useState(0);
const faviconUrl = trace.host ? faviconUrls(trace.host)[faviconIndex] : undefined;
useEffect(() => setFaviconIndex(0), [trace.host]);
const faviconCandidates = useMemo(() => (trace.host ? faviconUrls(trace.host) : []), [trace.host]);
const {
logoUrl: faviconUrl,
onLogoError: onFaviconError,
onLogoLoad: onFaviconLoad,
} = useLogoFallback(faviconCandidates);
if (trace.url && trace.host && faviconUrl) {
return (
@@ -962,7 +965,10 @@ function TraceIconMark({
src={faviconUrl}
alt=""
className="h-3.5 w-3.5 object-contain"
onError={() => setFaviconIndex((index) => index + 1)}
decoding="async"
loading="lazy"
onLoad={onFaviconLoad}
onError={onFaviconError}
/>
</span>
);
@@ -1661,19 +1667,16 @@ function CliRunGroup({
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
const { t } = useTranslation();
const [logoIndex, setLogoIndex] = useState(0);
const args = formatCliArgs(run);
const failed = run.status === "error";
const rowActive = active && run.status === "running";
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
const logoUrls = useMemo(() => logoFallbackUrls(app?.logo_url), [app?.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const label = t(cliRunLabelKey(run, active), {
defaultValue: cliRunLabelDefault(run, active),
});
useEffect(() => setLogoIndex(0), [app?.logo_url]);
return (
<ActivityStep
as="li"
@@ -1699,8 +1702,11 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
@@ -1772,19 +1778,16 @@ function McpRunGroup({
function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolean; preset?: McpPresetInfo }) {
const { t } = useTranslation();
const [logoIndex, setLogoIndex] = useState(0);
const failed = run.status === "error";
const rowActive = active && run.status === "running";
const color = failed ? "#DC2626" : preset?.brand_color || "#6D5DF6";
const logoUrls = useMemo(() => logoFallbackUrls(preset?.logo_url), [preset?.logo_url]);
const logoUrl = logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const displayName = preset?.display_name || run.displayName;
const label = t(mcpRunLabelKey(run, active), {
defaultValue: mcpRunLabelDefault(run, active),
});
useEffect(() => setLogoIndex(0), [preset?.logo_url]);
return (
<ActivityStep
as="li"
@@ -1810,8 +1813,11 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-[78%] w-[78%] object-contain"
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : preset ? (
mcpPresetInitials(preset).slice(0, 2)
+11 -10
View File
@@ -65,6 +65,7 @@ import {
type RestoredReadyImage,
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
import type {
@@ -2260,15 +2261,12 @@ function ComposerModelBadge({
}) {
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
const brand = providerBrand(inferredProvider);
const [logoIndex, setLogoIndex] = useState(0);
const logoUrl = brand?.logoUrls[logoIndex];
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
const showLogo = !!logoUrl;
const title = providerLabel ? `${label} · ${providerLabel}` : label;
const interactive = Boolean(onClick);
const Container = interactive ? "button" : "span";
useEffect(() => setLogoIndex(0), [inferredProvider]);
return (
<Container
title={title}
@@ -2305,8 +2303,11 @@ function ComposerModelBadge({
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
) : brand ? (
<span
@@ -2502,15 +2503,12 @@ function MentionCandidateLogo({
candidate: MentionCandidate;
selected: boolean;
}) {
const [logoIndex, setLogoIndex] = useState(0);
const color = (candidate.kind === "cli"
? candidate.app.brand_color
: candidate.preset.brand_color) || "hsl(var(--primary))";
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
const logoUrl = logoUrls[logoIndex];
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
if (logoUrl) {
return (
@@ -2523,8 +2521,11 @@ function MentionCandidateLogo({
<img
src={logoUrl}
alt=""
decoding="async"
loading="lazy"
className="h-5 w-5 object-contain"
onError={() => setLogoIndex((index) => index + 1)}
onLoad={onLogoLoad}
onError={onLogoError}
/>
</span>
);
+78
View File
@@ -0,0 +1,78 @@
import { useCallback, useEffect, useMemo, useState } from "react";
const loadedLogoUrls = new Set<string>();
const failedLogoUrls = new Set<string>();
const resolvedLogoIndexByKey = new Map<string, number>();
function logoCacheKey(urls: readonly string[]): string {
return urls.join("\n");
}
function logoUrlsFromKey(key: string): string[] {
return key ? key.split("\n") : [];
}
function firstUsableLogoIndex(urls: readonly string[]): number {
const key = logoCacheKey(urls);
const cachedIndex = resolvedLogoIndexByKey.get(key);
if (
typeof cachedIndex === "number" &&
cachedIndex >= 0 &&
cachedIndex < urls.length &&
!failedLogoUrls.has(urls[cachedIndex])
) {
return cachedIndex;
}
const loadedIndex = urls.findIndex((url) => loadedLogoUrls.has(url));
if (loadedIndex >= 0) {
resolvedLogoIndexByKey.set(key, loadedIndex);
return loadedIndex;
}
const firstUnfailedIndex = urls.findIndex((url) => !failedLogoUrls.has(url));
if (firstUnfailedIndex >= 0) return firstUnfailedIndex;
return -1;
}
function nextLogoIndex(urls: readonly string[], afterIndex: number): number {
for (let index = afterIndex + 1; index < urls.length; index += 1) {
if (!failedLogoUrls.has(urls[index])) return index;
}
return -1;
}
export function useLogoFallback(urls: readonly string[] | undefined) {
const cacheKey = useMemo(() => logoCacheKey(urls?.filter(Boolean) ?? []), [urls]);
const safeUrls = useMemo(() => logoUrlsFromKey(cacheKey), [cacheKey]);
const [logoIndex, setLogoIndex] = useState(() => firstUsableLogoIndex(safeUrls));
const logoUrl = logoIndex >= 0 ? safeUrls[logoIndex] : undefined;
useEffect(() => {
setLogoIndex(firstUsableLogoIndex(safeUrls));
}, [cacheKey, safeUrls]);
const onLogoLoad = useCallback(() => {
if (!logoUrl || logoIndex < 0) return;
loadedLogoUrls.add(logoUrl);
failedLogoUrls.delete(logoUrl);
resolvedLogoIndexByKey.set(cacheKey, logoIndex);
}, [cacheKey, logoIndex, logoUrl]);
const onLogoError = useCallback(() => {
if (!logoUrl || logoIndex < 0) return;
failedLogoUrls.add(logoUrl);
if (resolvedLogoIndexByKey.get(cacheKey) === logoIndex) {
resolvedLogoIndexByKey.delete(cacheKey);
}
setLogoIndex(nextLogoIndex(safeUrls, logoIndex));
}, [cacheKey, logoIndex, logoUrl, safeUrls]);
return { logoUrl, onLogoLoad, onLogoError };
}
export function __clearLogoFallbackCacheForTests(): void {
loadedLogoUrls.clear();
failedLogoUrls.clear();
resolvedLogoIndexByKey.clear();
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
export function useMediaQuery(query: string, fallback = false): boolean {
const readMatch = () => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return fallback;
}
return window.matchMedia(query).matches;
};
const [matches, setMatches] = useState(readMatch);
useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
)
return;
const media = window.matchMedia(query);
const update = () => setMatches(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [query]);
return matches;
}
+76 -21
View File
@@ -76,6 +76,7 @@
"image": "Image",
"voice": "Voice",
"browser": "Web",
"channels": "Channels",
"cliApps": "CLI Apps",
"mcp": "MCP",
"runtime": "System",
@@ -236,21 +237,21 @@
"searchPlaceholder": "Search CLIs",
"loading": "Loading CLI Apps...",
"empty": "No CLI Apps match this filter.",
"statusInstalled": "CLI installed",
"statusInstalled": "App ready",
"statusMissing": "Missing",
"statusAvailable": "Available",
"statusUnsupported": "Unsupported",
"statusNotInstalled": "CLI not installed",
"statusNotInstalled": "App not installed",
"requires": "Requires",
"test": "Test CLI",
"update": "Update CLI",
"uninstall": "Uninstall CLI",
"install": "Install CLI",
"test": "Test app",
"update": "Update app",
"uninstall": "Uninstall app",
"install": "Install app",
"readyTitle": "@{{name}} is ready",
"readyStatus": "Ready",
"readyTry": "Try @{{name}}",
"readyCopied": "Copied",
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
"readyPrompt": "Ask nanobot to use @{{name}} for this task.",
"openChat": "Open chat",
"unsupported": "Unsupported",
"unavailable": "Unavailable",
@@ -270,8 +271,8 @@
"filterInstalled": "Enabled",
"filterNotInstalled": "Not enabled",
"searchPlaceholder": "Search MCP presets",
"moreOptions": "More MCP options",
"moreOptionsSubtitle": "Add a custom server or import mcp.json.",
"moreOptions": "Add integration",
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
"customTitle": "Custom MCP",
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
"customAction": "Custom",
@@ -462,23 +463,77 @@
"configureProvider": "Configure provider",
"missingCredential": "Configure this provider before enabling image generation."
},
"api": {
"title": "API server",
"openaiCompatible": "OpenAI-compatible API",
"description": "Connect SDKs and agents through a local /v1 endpoint.",
"start": "Start API server",
"starting": "Starting...",
"stop": "Stop",
"stopping": "Stopping...",
"access": "Access",
"thisDevice": "This device",
"localNetwork": "Local network",
"localHelp": "Only this device can connect.",
"networkHelp": "Other devices can connect; an API key is required.",
"port": "Port",
"portHelp": "The API uses this local port.",
"apiKey": "API key",
"apiKeyHelp": "Clients send this as a Bearer token.",
"apiKeyRequired": "Required before exposing the API to your network.",
"apiKeyPlaceholder": "Enter an API key",
"autoInstall": "API support will be installed automatically when you start it."
},
"observability": {
"title": "Observability",
"configured": "Tracing credentials are available to nanobot.",
"environment": "Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
"enable": "Enable tracing support"
},
"apps": {
"description": "Enable plugins, local app adapters, and connected tool servers.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "Add tools to nanobot, then @ them in chat.",
"cliLabel": "App",
"mcpLabel": "Integration",
"channelLabel": "Channel",
"featureLabel": "Feature",
"filterAll": "All",
"filterAll": "Ready",
"filterPlugins": "Plugins",
"filterCli": "CLI apps",
"filterMcp": "MCP services",
"enabledSummary": "{{count}} enabled",
"caption": "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
"searchPlaceholder": "Search Apps",
"featured": "Catalog",
"filterCli": "Apps",
"filterMcp": "Integrations",
"enabledSummary": "{{count}} ready",
"caption": "{{cli}} apps · {{mcp}} integrations",
"searchPlaceholder": "Search tools",
"featured": "Tools",
"loading": "Loading Apps...",
"empty": "No apps match this filter.",
"restartRequired": "Restart nanobot to apply updated apps and features."
"empty": "No tools match this view.",
"restartRequired": "Restart nanobot to apply updated apps and integrations."
},
"channels": {
"description": "Connect chat apps, email, and WebUI to nanobot.",
"caption": "{{enabled}} enabled · {{total}} channels",
"searchPlaceholder": "Search channels",
"backToChannels": "All channels",
"catalog": "Channels",
"loading": "Loading Channels...",
"empty": "No channels match this filter.",
"restartRequired": "Restart nanobot to apply updated channel support.",
"requires": "Requires: {{requirements}}",
"setUp": "Set up",
"setupGuide": "Setup guide",
"setupSummary": "Enable only turns on nanobot support. Add the platform credentials, then restart nanobot.",
"configKeys": "Config keys",
"enable": "Enable channel",
"disable": "Disable channel",
"needsConfig": "Needs setup",
"connect": "Connect",
"reconnect": "Reconnect",
"feishuQrAlt": "Feishu connection QR code",
"feishuScanTitle": "Scan with Feishu",
"feishuScanDescription": "Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
"feishuWaiting": "Waiting for authorization...",
"feishuConnected": "Feishu is connected.",
"feishuConnectStopped": "Connection stopped.",
"feishuConnecting": "Connecting..."
},
"nanobotFeatures": {
"enabled": "Enabled",
+52 -10
View File
@@ -76,6 +76,7 @@
"image": "Imagen",
"voice": "Voz",
"browser": "Internet",
"channels": "Canales",
"runtime": "Sistema",
"advanced": "Seguridad",
"cliApps": "Apps CLI",
@@ -459,27 +460,68 @@
"noTools": "Ninguna",
"testForTools": "Ejecuta Probar para inspeccionar y elegir herramientas individuales."
},
"api": {
"title": "Servidor API", "openaiCompatible": "API compatible con OpenAI",
"description": "Conecta SDK y agentes mediante un endpoint /v1 local.",
"start": "Iniciar servidor API", "starting": "Iniciando...", "stop": "Detener", "stopping": "Deteniendo...",
"access": "Acceso", "thisDevice": "Este dispositivo", "localNetwork": "Red local",
"localHelp": "Solo este dispositivo puede conectarse.", "networkHelp": "Otros dispositivos pueden conectarse; se requiere una clave API.",
"port": "Puerto", "portHelp": "La API usa este puerto local.", "apiKey": "Clave API",
"apiKeyHelp": "Los clientes la envían como token Bearer.", "apiKeyRequired": "Obligatoria antes de exponer la API en la red.",
"apiKeyPlaceholder": "Introduce una clave API", "autoInstall": "El soporte API se instalará automáticamente al iniciarlo."
},
"observability": {
"title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.",
"environment": "Configura LANGFUSE_SECRET_KEY y LANGFUSE_PUBLIC_KEY y reinicia nanobot.", "enable": "Habilitar soporte de trazas"
},
"legal": {
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
},
"apps": {
"description": "Activa complementos, adaptadores locales de apps y servidores de herramientas conectados.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
"cliLabel": "App",
"mcpLabel": "Integración",
"channelLabel": "Canal",
"featureLabel": "Función",
"filterAll": "Todo",
"filterAll": "Listo",
"filterPlugins": "Complementos",
"filterCli": "Apps CLI",
"filterMcp": "Servicios MCP",
"enabledSummary": "{{count}} activados",
"caption": "{{plugins}} complementos · {{cli}} CLI · {{mcp}} MCP",
"filterCli": "Apps",
"filterMcp": "Integraciones",
"enabledSummary": "{{count}} listos",
"caption": "{{cli}} apps · {{mcp}} integraciones",
"searchPlaceholder": "Buscar apps",
"featured": "Catálogo",
"featured": "Herramientas",
"loading": "Cargando apps...",
"empty": "Ninguna app coincide con este filtro.",
"empty": "Ninguna herramienta coincide con esta vista.",
"restartRequired": "Reinicia nanobot para aplicar apps y funciones actualizadas."
},
"channels": {
"description": "Conecta nanobot con apps de chat. Instalar soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
"caption": "{{enabled}} activados · {{total}} canales",
"searchPlaceholder": "Buscar canales",
"backToChannels": "Todos los canales",
"catalog": "Canales",
"loading": "Cargando canales...",
"empty": "Ningún canal coincide con este filtro.",
"restartRequired": "Reinicia nanobot para aplicar el soporte de canales actualizado.",
"requires": "Requiere: {{requirements}}",
"setUp": "Configurar",
"setupGuide": "Guía de configuración",
"setupSummary": "Activar solo habilita el soporte en nanobot. Añade las credenciales de la plataforma y reinicia nanobot.",
"configKeys": "Claves de configuración",
"enable": "Activar canal",
"disable": "Desactivar canal",
"needsConfig": "Necesita configuración",
"connect": "Conectar",
"reconnect": "Reconectar",
"feishuQrAlt": "Código QR de conexión de Feishu",
"feishuScanTitle": "Escanea con Feishu",
"feishuScanDescription": "Usa Feishu o Lark en tu teléfono para escanear este código. nanobot terminará la configuración automáticamente después de la autorización.",
"feishuWaiting": "Esperando autorización...",
"feishuConnected": "Feishu está conectado.",
"feishuConnectStopped": "Conexión detenida.",
"feishuConnecting": "Conectando..."
},
"nanobotFeatures": {
"enabled": "Activado",
"enable": "Activar",
+51 -10
View File
@@ -76,6 +76,7 @@
"image": "Images",
"voice": "Voix",
"browser": "Internet",
"channels": "Canaux",
"runtime": "Système",
"advanced": "Sécurité",
"cliApps": "Apps CLI",
@@ -459,27 +460,67 @@
"noTools": "Aucun",
"testForTools": "Exécutez Tester pour inspecter et choisir des outils individuels."
},
"api": {
"title": "Serveur API", "openaiCompatible": "API compatible OpenAI", "description": "Connectez des SDK et agents via un endpoint /v1 local.",
"start": "Démarrer le serveur API", "starting": "Démarrage...", "stop": "Arrêter", "stopping": "Arrêt...",
"access": "Accès", "thisDevice": "Cet appareil", "localNetwork": "Réseau local",
"localHelp": "Seul cet appareil peut se connecter.", "networkHelp": "Dautres appareils peuvent se connecter ; une clé API est requise.",
"port": "Port", "portHelp": "LAPI utilise ce port local.", "apiKey": "Clé API", "apiKeyHelp": "Les clients lenvoient comme jeton Bearer.",
"apiKeyRequired": "Requise avant dexposer lAPI au réseau.", "apiKeyPlaceholder": "Saisissez une clé API",
"autoInstall": "Le support API sera installé automatiquement au démarrage."
},
"observability": {
"title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.",
"environment": "Définissez LANGFUSE_SECRET_KEY et LANGFUSE_PUBLIC_KEY, puis redémarrez nanobot.", "enable": "Activer le traçage"
},
"legal": {
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
},
"apps": {
"description": "Activez des extensions, des adaptateurs dapps locales et des serveurs doutils connectés.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
"cliLabel": "App",
"mcpLabel": "Intégration",
"channelLabel": "Canal",
"featureLabel": "Fonction",
"filterAll": "Tout",
"filterAll": "Prêts",
"filterPlugins": "Extensions",
"filterCli": "Apps CLI",
"filterMcp": "Services MCP",
"enabledSummary": "{{count}} activés",
"caption": "{{plugins}} extensions · {{cli}} CLI · {{mcp}} MCP",
"filterCli": "Apps",
"filterMcp": "Intégrations",
"enabledSummary": "{{count}} prêts",
"caption": "{{cli}} apps · {{mcp}} intégrations",
"searchPlaceholder": "Rechercher des apps",
"featured": "Catalogue",
"featured": "Outils",
"loading": "Chargement des apps...",
"empty": "Aucune app ne correspond.",
"empty": "Aucun outil ne correspond à cette vue.",
"restartRequired": "Redémarrez nanobot pour appliquer les apps et fonctions mises à jour."
},
"channels": {
"description": "Connectez nanobot aux apps de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des tokens ou des réglages d'espace de travail.",
"caption": "{{enabled}} activés · {{total}} canaux",
"searchPlaceholder": "Rechercher des canaux",
"backToChannels": "Tous les canaux",
"catalog": "Canaux",
"loading": "Chargement des canaux...",
"empty": "Aucun canal ne correspond à ce filtre.",
"restartRequired": "Redémarrez nanobot pour appliquer le support de canal mis à jour.",
"requires": "Requiert : {{requirements}}",
"setUp": "Configurer",
"setupGuide": "Guide de configuration",
"setupSummary": "L'activation n'active que le support nanobot. Ajoutez les identifiants de la plateforme, puis redémarrez nanobot.",
"configKeys": "Clés de configuration",
"enable": "Activer le canal",
"disable": "Désactiver le canal",
"needsConfig": "Configuration requise",
"connect": "Connecter",
"reconnect": "Reconnecter",
"feishuQrAlt": "QR code de connexion Feishu",
"feishuScanTitle": "Scanner avec Feishu",
"feishuScanDescription": "Utilisez Feishu ou Lark sur votre téléphone pour scanner ce code. nanobot terminera la configuration automatiquement après l'autorisation.",
"feishuWaiting": "En attente d'autorisation...",
"feishuConnected": "Feishu est connecté.",
"feishuConnectStopped": "Connexion arrêtée.",
"feishuConnecting": "Connexion..."
},
"nanobotFeatures": {
"enabled": "Activé",
"enable": "Activer",
+51 -10
View File
@@ -76,6 +76,7 @@
"image": "Gambar",
"voice": "Suara",
"browser": "Internet",
"channels": "Kanal",
"runtime": "Sistem",
"advanced": "Keamanan",
"cliApps": "Aplikasi CLI",
@@ -459,27 +460,67 @@
"noTools": "Tidak ada",
"testForTools": "Jalankan Uji untuk memeriksa dan memilih alat individual."
},
"api": {
"title": "Server API", "openaiCompatible": "API kompatibel OpenAI", "description": "Hubungkan SDK dan agen melalui endpoint /v1 lokal.",
"start": "Mulai server API", "starting": "Memulai...", "stop": "Hentikan", "stopping": "Menghentikan...",
"access": "Akses", "thisDevice": "Perangkat ini", "localNetwork": "Jaringan lokal",
"localHelp": "Hanya perangkat ini yang dapat terhubung.", "networkHelp": "Perangkat lain dapat terhubung; kunci API diperlukan.",
"port": "Port", "portHelp": "API menggunakan port lokal ini.", "apiKey": "Kunci API", "apiKeyHelp": "Klien mengirimkannya sebagai token Bearer.",
"apiKeyRequired": "Wajib sebelum membuka API ke jaringan.", "apiKeyPlaceholder": "Masukkan kunci API",
"autoInstall": "Dukungan API akan dipasang otomatis saat dimulai."
},
"observability": {
"title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.",
"environment": "Atur LANGFUSE_SECRET_KEY dan LANGFUSE_PUBLIC_KEY, lalu mulai ulang nanobot.", "enable": "Aktifkan dukungan tracing"
},
"legal": {
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
},
"apps": {
"description": "Aktifkan plugin, adaptor aplikasi lokal, dan server alat terhubung.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
"cliLabel": "Aplikasi",
"mcpLabel": "Integrasi",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
"filterAll": "Semua",
"filterAll": "Siap",
"filterPlugins": "Plugin",
"filterCli": "Aplikasi CLI",
"filterMcp": "Layanan MCP",
"enabledSummary": "{{count}} aktif",
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
"filterCli": "Aplikasi",
"filterMcp": "Integrasi",
"enabledSummary": "{{count}} siap",
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
"searchPlaceholder": "Cari aplikasi",
"featured": "Katalog",
"featured": "Alat",
"loading": "Memuat aplikasi...",
"empty": "Tidak ada aplikasi yang cocok.",
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
},
"channels": {
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan workspace.",
"caption": "{{enabled}} aktif · {{total}} kanal",
"searchPlaceholder": "Cari kanal",
"backToChannels": "Semua kanal",
"catalog": "Kanal",
"loading": "Memuat kanal...",
"empty": "Tidak ada kanal yang cocok dengan filter ini.",
"restartRequired": "Mulai ulang nanobot untuk menerapkan dukungan kanal yang diperbarui.",
"requires": "Memerlukan: {{requirements}}",
"setUp": "Siapkan",
"setupGuide": "Panduan setup",
"setupSummary": "Mengaktifkan hanya menyalakan dukungan nanobot. Tambahkan kredensial platform, lalu mulai ulang nanobot.",
"configKeys": "Kunci konfigurasi",
"enable": "Aktifkan kanal",
"disable": "Nonaktifkan kanal",
"needsConfig": "Perlu konfigurasi",
"connect": "Hubungkan",
"reconnect": "Hubungkan ulang",
"feishuQrAlt": "Kode QR koneksi Feishu",
"feishuScanTitle": "Pindai dengan Feishu",
"feishuScanDescription": "Gunakan Feishu atau Lark di ponsel untuk memindai kode ini. nanobot akan menyelesaikan setup secara otomatis setelah otorisasi.",
"feishuWaiting": "Menunggu otorisasi...",
"feishuConnected": "Feishu sudah terhubung.",
"feishuConnectStopped": "Koneksi dihentikan.",
"feishuConnecting": "Menghubungkan..."
},
"nanobotFeatures": {
"enabled": "Aktif",
"enable": "Aktifkan",
+51 -10
View File
@@ -76,6 +76,7 @@
"image": "画像",
"voice": "音声",
"browser": "ウェブ",
"channels": "チャンネル",
"runtime": "システム",
"advanced": "セキュリティ",
"cliApps": "CLI アプリ",
@@ -459,27 +460,67 @@
"noTools": "なし",
"testForTools": "テストを実行して個別のツールを確認・選択します。"
},
"api": {
"title": "API サーバー", "openaiCompatible": "OpenAI 互換 API", "description": "ローカルの /v1 エンドポイントから SDK やエージェントを接続します。",
"start": "API サーバーを起動", "starting": "起動中...", "stop": "停止", "stopping": "停止中...",
"access": "アクセス", "thisDevice": "このデバイス", "localNetwork": "ローカルネットワーク",
"localHelp": "このデバイスだけが接続できます。", "networkHelp": "他のデバイスも接続できるため API キーが必要です。",
"port": "ポート", "portHelp": "API が使用するローカルポートです。", "apiKey": "API キー", "apiKeyHelp": "クライアントは Bearer トークンとして送信します。",
"apiKeyRequired": "ネットワークに公開する前に必要です。", "apiKeyPlaceholder": "API キーを入力",
"autoInstall": "起動時に API サポートを自動インストールします。"
},
"observability": {
"title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。",
"environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化"
},
"legal": {
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
},
"apps": {
"description": "プラグイン、ローカルアプリアダプター、接続済みツールサーバーを有効にします。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
"cliLabel": "アプリ",
"mcpLabel": "連携",
"channelLabel": "チャンネル",
"featureLabel": "機能",
"filterAll": "すべて",
"filterAll": "使用可能",
"filterPlugins": "プラグイン",
"filterCli": "CLI アプリ",
"filterMcp": "MCP サービス",
"enabledSummary": "{{count}} 件有効",
"caption": "{{plugins}} 件のプラグイン · CLI {{cli}} 件 · MCP {{mcp}} 件",
"filterCli": "アプリ",
"filterMcp": "連携",
"enabledSummary": "{{count}} 件使用可能",
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
"searchPlaceholder": "アプリを検索",
"featured": "カタログ",
"featured": "ツール",
"loading": "アプリを読み込み中...",
"empty": "一致するアプリはありません。",
"empty": "この表示に一致するツールはありません。",
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
},
"channels": {
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
"caption": "{{enabled}} 件有効 · 全 {{total}} チャンネル",
"searchPlaceholder": "チャンネルを検索",
"backToChannels": "すべてのチャンネル",
"catalog": "チャンネル",
"loading": "チャンネルを読み込み中...",
"empty": "一致するチャンネルはありません。",
"restartRequired": "更新したチャンネルサポートを反映するには nanobot を再起動してください。",
"requires": "必要: {{requirements}}",
"setUp": "設定",
"setupGuide": "設定ガイド",
"setupSummary": "有効化は nanobot 側のサポートをオンにするだけです。プラットフォームの認証情報を追加してから nanobot を再起動してください。",
"configKeys": "設定キー",
"enable": "チャンネルを有効化",
"disable": "チャンネルを無効化",
"needsConfig": "設定が必要",
"connect": "接続",
"reconnect": "再接続",
"feishuQrAlt": "Feishu 接続 QR コード",
"feishuScanTitle": "Feishu でスキャン",
"feishuScanDescription": "スマートフォンの Feishu または Lark でこのコードをスキャンしてください。認可後、nanobot が自動で設定を完了します。",
"feishuWaiting": "認可を待っています...",
"feishuConnected": "Feishu に接続しました。",
"feishuConnectStopped": "接続を停止しました。",
"feishuConnecting": "接続中..."
},
"nanobotFeatures": {
"enabled": "有効",
"enable": "有効化",
+51 -10
View File
@@ -76,6 +76,7 @@
"image": "이미지",
"voice": "음성",
"browser": "웹",
"channels": "채널",
"runtime": "시스템",
"advanced": "보안",
"cliApps": "CLI 앱",
@@ -459,27 +460,67 @@
"noTools": "없음",
"testForTools": "테스트를 실행해 개별 도구를 확인하고 선택하세요."
},
"api": {
"title": "API 서버", "openaiCompatible": "OpenAI 호환 API", "description": "로컬 /v1 엔드포인트로 SDK와 에이전트를 연결합니다.",
"start": "API 서버 시작", "starting": "시작 중...", "stop": "중지", "stopping": "중지 중...",
"access": "접근", "thisDevice": "이 기기", "localNetwork": "로컬 네트워크",
"localHelp": "이 기기만 연결할 수 있습니다.", "networkHelp": "다른 기기도 연결할 수 있으므로 API 키가 필요합니다.",
"port": "포트", "portHelp": "API가 사용할 로컬 포트입니다.", "apiKey": "API 키", "apiKeyHelp": "클라이언트는 Bearer 토큰으로 전송합니다.",
"apiKeyRequired": "네트워크에 공개하기 전에 필요합니다.", "apiKeyPlaceholder": "API 키 입력",
"autoInstall": "시작할 때 API 지원을 자동으로 설치합니다."
},
"observability": {
"title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.",
"environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화"
},
"legal": {
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
},
"apps": {
"description": "플러그인, 로컬 앱 어댑터, 연결된 도구 서버를 활성화합니다.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
"cliLabel": "",
"mcpLabel": "연동",
"channelLabel": "채널",
"featureLabel": "기능",
"filterAll": "전체",
"filterAll": "사용 가능",
"filterPlugins": "플러그인",
"filterCli": "CLI 앱",
"filterMcp": "MCP 서비스",
"enabledSummary": "{{count}}개 활성화됨",
"caption": "플러그인 {{plugins}}개 · CLI {{cli}}개 · MCP {{mcp}}개",
"filterCli": "앱",
"filterMcp": "연동",
"enabledSummary": "{{count}}개 사용 가능",
"caption": " {{cli}}개 · 연동 {{mcp}}개",
"searchPlaceholder": "앱 검색",
"featured": "카탈로그",
"featured": "도구",
"loading": "앱을 불러오는 중...",
"empty": "일치하는 앱이 없습니다.",
"empty": "이 보기에 일치하는 도구가 없습니다.",
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
},
"channels": {
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
"caption": "{{enabled}}개 활성화됨 · 총 {{total}}개 채널",
"searchPlaceholder": "채널 검색",
"backToChannels": "모든 채널",
"catalog": "채널",
"loading": "채널을 불러오는 중...",
"empty": "일치하는 채널이 없습니다.",
"restartRequired": "업데이트된 채널 지원을 적용하려면 nanobot을 다시 시작하세요.",
"requires": "필요: {{requirements}}",
"setUp": "설정",
"setupGuide": "설정 가이드",
"setupSummary": "활성화는 nanobot 지원만 켭니다. 플랫폼 자격 증명을 추가한 뒤 nanobot을 다시 시작하세요.",
"configKeys": "설정 키",
"enable": "채널 활성화",
"disable": "채널 비활성화",
"needsConfig": "설정 필요",
"connect": "연결",
"reconnect": "다시 연결",
"feishuQrAlt": "Feishu 연결 QR 코드",
"feishuScanTitle": "Feishu로 스캔",
"feishuScanDescription": "휴대폰의 Feishu 또는 Lark로 이 코드를 스캔하세요. 승인 후 nanobot이 자동으로 설정을 완료합니다.",
"feishuWaiting": "승인을 기다리는 중...",
"feishuConnected": "Feishu가 연결되었습니다.",
"feishuConnectStopped": "연결이 중지되었습니다.",
"feishuConnecting": "연결 중..."
},
"nanobotFeatures": {
"enabled": "활성화됨",
"enable": "활성화",
+51 -10
View File
@@ -76,6 +76,7 @@
"image": "Hình ảnh",
"voice": "Giọng nói",
"browser": "Trang web",
"channels": "Kênh",
"runtime": "Hệ thống",
"advanced": "Bảo mật",
"cliApps": "Ứng dụng CLI",
@@ -459,27 +460,67 @@
"noTools": "Không có",
"testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
},
"api": {
"title": "Máy chủ API", "openaiCompatible": "API tương thích OpenAI", "description": "Kết nối SDK và agent qua endpoint /v1 cục bộ.",
"start": "Khởi động API", "starting": "Đang khởi động...", "stop": "Dừng", "stopping": "Đang dừng...",
"access": "Truy cập", "thisDevice": "Thiết bị này", "localNetwork": "Mạng nội bộ",
"localHelp": "Chỉ thiết bị này có thể kết nối.", "networkHelp": "Thiết bị khác có thể kết nối; cần khóa API.",
"port": "Cổng", "portHelp": "API sử dụng cổng cục bộ này.", "apiKey": "Khóa API", "apiKeyHelp": "Client gửi khóa dưới dạng Bearer token.",
"apiKeyRequired": "Bắt buộc trước khi mở API ra mạng.", "apiKeyPlaceholder": "Nhập khóa API",
"autoInstall": "Hỗ trợ API sẽ tự động được cài khi khởi động."
},
"observability": {
"title": "Khả năng quan sát", "configured": "Thông tin xác thực tracing đã sẵn sàng cho nanobot.",
"environment": "Đặt LANGFUSE_SECRET_KEY và LANGFUSE_PUBLIC_KEY rồi khởi động lại nanobot.", "enable": "Bật hỗ trợ tracing"
},
"legal": {
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
},
"apps": {
"description": "Bật plugin, bộ chuyển đổi ứng dng cục bộ và máy chủ công cụ đã kết nối.",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
"cliLabel": "Ứng dụng",
"mcpLabel": "Tích hợp",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
"filterAll": "Tất cả",
"filterAll": "Sẵn sàng",
"filterPlugins": "Plugin",
"filterCli": "Ứng dụng CLI",
"filterMcp": "Dịch vụ MCP",
"enabledSummary": "{{count}} đã bật",
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
"filterCli": "Ứng dụng",
"filterMcp": "ch hợp",
"enabledSummary": "{{count}} sẵn sàng",
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
"searchPlaceholder": "Tìm ứng dụng",
"featured": "Danh mục",
"featured": "Công cụ",
"loading": "Đang tải ứng dụng...",
"empty": "Không có ng dụng phù hợp.",
"empty": "Không có ng cụ phù hợp với chế độ xem này.",
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
},
"channels": {
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình workspace.",
"caption": "{{enabled}} đã bật · {{total}} kênh",
"searchPlaceholder": "Tìm kênh",
"backToChannels": "Tất cả kênh",
"catalog": "Kênh",
"loading": "Đang tải kênh...",
"empty": "Không có kênh nào phù hợp.",
"restartRequired": "Khởi động lại nanobot để áp dụng hỗ trợ kênh đã cập nhật.",
"requires": "Yêu cầu: {{requirements}}",
"setUp": "Thiết lập",
"setupGuide": "Hướng dẫn thiết lập",
"setupSummary": "Bật chỉ kích hoạt hỗ trợ của nanobot. Thêm thông tin xác thực nền tảng rồi khởi động lại nanobot.",
"configKeys": "Khóa cấu hình",
"enable": "Bật kênh",
"disable": "Tắt kênh",
"needsConfig": "Cần cấu hình",
"connect": "Kết nối",
"reconnect": "Kết nối lại",
"feishuQrAlt": "Mã QR kết nối Feishu",
"feishuScanTitle": "Quét bằng Feishu",
"feishuScanDescription": "Dùng Feishu hoặc Lark trên điện thoại để quét mã này. nanobot sẽ tự hoàn tất cấu hình sau khi cấp quyền.",
"feishuWaiting": "Đang chờ cấp quyền...",
"feishuConnected": "Feishu đã kết nối.",
"feishuConnectStopped": "Kết nối đã dừng.",
"feishuConnecting": "Đang kết nối..."
},
"nanobotFeatures": {
"enabled": "Đã bật",
"enable": "Bật",
+76 -21
View File
@@ -76,6 +76,7 @@
"image": "图片",
"voice": "语音",
"browser": "网页",
"channels": "渠道",
"cliApps": "CLI 应用",
"mcp": "MCP",
"runtime": "系统",
@@ -236,21 +237,21 @@
"searchPlaceholder": "搜索 CLI",
"loading": "正在加载 CLI 应用...",
"empty": "没有匹配的 CLI 应用。",
"statusInstalled": "CLI 已安装",
"statusInstalled": "应用已就绪",
"statusMissing": "缺失",
"statusAvailable": "可用",
"statusUnsupported": "暂不支持",
"statusNotInstalled": "CLI 未安装",
"statusNotInstalled": "应用未安装",
"requires": "依赖",
"test": "测试 CLI",
"update": "更新 CLI",
"uninstall": "卸载 CLI",
"install": "安装 CLI",
"test": "测试应用",
"update": "更新应用",
"uninstall": "卸载应用",
"install": "安装应用",
"readyTitle": "@{{name}} 已就绪",
"readyStatus": "就绪",
"readyTry": "试试 @{{name}}",
"readyCopied": "已复制",
"readyPrompt": "用 @{{name}} 看看这个 CLI 能做什么。",
"readyPrompt": "让 nanobot 在这个任务中使用 @{{name}}。",
"openChat": "回到对话",
"unsupported": "暂不支持",
"unavailable": "不可用",
@@ -270,8 +271,8 @@
"filterInstalled": "已启用",
"filterNotInstalled": "未启用",
"searchPlaceholder": "搜索 MCP 预设",
"moreOptions": "更多 MCP 选项",
"moreOptionsSubtitle": "添加自定义服务,或导入 mcp.json。",
"moreOptions": "添加集成",
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
"customTitle": "自定义 MCP",
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
"customAction": "自定义",
@@ -462,23 +463,77 @@
"configureProvider": "配置提供商",
"missingCredential": "启用图片生成前请先配置此提供商。"
},
"api": {
"title": "API 服务",
"openaiCompatible": "OpenAI 兼容 API",
"description": "让 SDK 和其他 Agent 通过本地 /v1 接口连接 nanobot。",
"start": "启动 API 服务",
"starting": "正在启动...",
"stop": "停止",
"stopping": "正在停止...",
"access": "访问范围",
"thisDevice": "仅此设备",
"localNetwork": "局域网",
"localHelp": "只有当前设备可以连接。",
"networkHelp": "局域网内其他设备可以连接,因此必须设置 API Key。",
"port": "端口",
"portHelp": "API 服务使用的本地端口。",
"apiKey": "API Key",
"apiKeyHelp": "客户端使用 Bearer Token 发送此密钥。",
"apiKeyRequired": "向局域网开放 API 前必须设置密钥。",
"apiKeyPlaceholder": "输入 API Key",
"autoInstall": "启动时会自动安装 API 支持。"
},
"observability": {
"title": "可观测性",
"configured": "nanobot 已检测到追踪凭证。",
"environment": "设置 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 后重启 nanobot。",
"enable": "启用追踪支持"
},
"apps": {
"description": "启用插件、本地应用适配器和已连接的工具服务。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "把工具连接到 nanobot,然后在对话中 @ 使用。",
"cliLabel": "应用",
"mcpLabel": "集成",
"channelLabel": "渠道",
"featureLabel": "能力",
"filterAll": "全部",
"filterAll": "可用",
"filterPlugins": "插件",
"filterCli": "CLI 应用",
"filterMcp": "MCP 服务",
"enabledSummary": "已启用 {{count}} 个",
"caption": "{{plugins}} 个插件 · {{cli}} 个 CLI · {{mcp}} 个 MCP",
"searchPlaceholder": "搜索应用",
"featured": "应用目录",
"filterCli": "应用",
"filterMcp": "集成",
"enabledSummary": "{{count}} 个可用",
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
"searchPlaceholder": "搜索工具",
"featured": "工具",
"loading": "正在加载应用...",
"empty": "没有匹配的应用。",
"restartRequired": "重启 nanobot 以应用更新后的应用和能力。"
"empty": "当前视图没有匹配的工具。",
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
},
"channels": {
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
"caption": "{{enabled}} 个已启用 · 共 {{total}} 个渠道",
"searchPlaceholder": "搜索渠道",
"backToChannels": "所有渠道",
"catalog": "渠道",
"loading": "正在加载渠道...",
"empty": "没有匹配的渠道。",
"restartRequired": "重启 nanobot 以应用更新后的渠道支持。",
"requires": "需要:{{requirements}}",
"setUp": "设置",
"setupGuide": "配置指南",
"setupSummary": "启用只会打开 nanobot 的渠道支持。请补充平台凭据,然后重启 nanobot。",
"configKeys": "配置字段",
"enable": "启用渠道",
"disable": "禁用渠道",
"needsConfig": "需要配置",
"connect": "连接",
"reconnect": "重新连接",
"feishuQrAlt": "飞书连接二维码",
"feishuScanTitle": "使用飞书扫码",
"feishuScanDescription": "用手机上的飞书或 Lark 扫描二维码。授权完成后,nanobot 会自动完成配置。",
"feishuWaiting": "正在等待授权...",
"feishuConnected": "飞书已连接。",
"feishuConnectStopped": "连接已停止。",
"feishuConnecting": "正在连接..."
},
"nanobotFeatures": {
"enabled": "已启用",
+51 -10
View File
@@ -76,6 +76,7 @@
"image": "圖片",
"voice": "語音",
"browser": "網頁",
"channels": "渠道",
"runtime": "系統",
"advanced": "安全",
"cliApps": "CLI 應用",
@@ -459,27 +460,67 @@
"noTools": "無",
"testForTools": "執行測試以檢查並選擇個別工具。"
},
"api": {
"title": "API 服務", "openaiCompatible": "OpenAI 相容 API", "description": "讓 SDK 和其他 Agent 透過本機 /v1 介面連接 nanobot。",
"start": "啟動 API 服務", "starting": "正在啟動...", "stop": "停止", "stopping": "正在停止...",
"access": "存取範圍", "thisDevice": "僅此裝置", "localNetwork": "區域網路",
"localHelp": "只有目前裝置可以連接。", "networkHelp": "區域網路內其他裝置可以連接,因此必須設定 API Key。",
"port": "連接埠", "portHelp": "API 服務使用的本機連接埠。", "apiKey": "API Key", "apiKeyHelp": "用戶端使用 Bearer Token 傳送此金鑰。",
"apiKeyRequired": "向區域網路開放 API 前必須設定金鑰。", "apiKeyPlaceholder": "輸入 API Key",
"autoInstall": "啟動時會自動安裝 API 支援。"
},
"observability": {
"title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。",
"environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援"
},
"legal": {
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
},
"apps": {
"description": "啟用插件、本機應用適配器和已連接的工具服務。",
"cliLabel": "CLI",
"mcpLabel": "MCP",
"description": "把工具連接到 nanobot,然後在對話中使用 @。",
"cliLabel": "應用",
"mcpLabel": "整合",
"channelLabel": "通道",
"featureLabel": "能力",
"filterAll": "全部",
"filterAll": "可用",
"filterPlugins": "插件",
"filterCli": "CLI 應用",
"filterMcp": "MCP 服務",
"enabledSummary": "已啟用 {{count}} 個",
"caption": "{{plugins}} 個插件 · {{cli}} 個 CLI · {{mcp}} 個 MCP",
"filterCli": "應用",
"filterMcp": "整合",
"enabledSummary": "{{count}} 個可用",
"caption": "{{cli}} 個應用 · {{mcp}} 個整合",
"searchPlaceholder": "搜尋應用",
"featured": "應用目錄",
"featured": "工具",
"loading": "正在載入應用...",
"empty": "沒有符合的應用。",
"empty": "目前檢視沒有符合的工具。",
"restartRequired": "重新啟動 nanobot 以套用更新後的應用和能力。"
},
"channels": {
"description": "把聊天應用、郵箱和 WebUI 連接到 nanobot。",
"caption": "{{enabled}} 個已啟用 · 共 {{total}} 個渠道",
"searchPlaceholder": "搜尋渠道",
"backToChannels": "所有渠道",
"catalog": "渠道",
"loading": "正在載入渠道...",
"empty": "沒有符合的渠道。",
"restartRequired": "重新啟動 nanobot 以套用更新後的渠道支援。",
"requires": "需要:{{requirements}}",
"setUp": "設定",
"setupGuide": "設定指南",
"setupSummary": "啟用只會打開 nanobot 的渠道支援。請補充平台憑證,然後重新啟動 nanobot。",
"configKeys": "設定欄位",
"enable": "啟用渠道",
"disable": "停用渠道",
"needsConfig": "需要設定",
"connect": "連接",
"reconnect": "重新連接",
"feishuQrAlt": "飛書連接二維碼",
"feishuScanTitle": "使用飛書掃碼",
"feishuScanDescription": "用手機上的飛書或 Lark 掃描二維碼。授權完成後,nanobot 會自動完成設定。",
"feishuWaiting": "正在等待授權...",
"feishuConnected": "飛書已連接。",
"feishuConnectStopped": "連接已停止。",
"feishuConnecting": "正在連接..."
},
"nanobotFeatures": {
"enabled": "已啟用",
"enable": "啟用",
+159
View File
@@ -1,6 +1,10 @@
import type {
ApiServicePayload,
AutomationsPayload,
AutomationUpdatePayload,
ChannelConfigurePayload,
ChannelConnectPayload,
ChannelValidationPayload,
ChatSummary,
CliAppsPayload,
FilePreviewPayload,
@@ -10,6 +14,7 @@ import type {
ModelConfigurationCreate,
ModelConfigurationUpdate,
NetworkSafetySettingsUpdate,
PairingPayload,
ProviderModelsPayload,
ProviderSettingsUpdate,
SessionDeleteResult,
@@ -44,6 +49,8 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
&& SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle)
);
}
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
export class ApiError extends Error {
status: number;
@@ -386,13 +393,43 @@ export async function fetchNanobotFeatures(
);
}
export async function fetchApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
return request<ApiServicePayload>(`${base}/api/settings/api-service`, token);
}
export async function startApiService(
token: string,
values: { host: string; port: number; timeout: number; apiKey?: string },
base: string = "",
): Promise<ApiServicePayload> {
const query = new URLSearchParams({
host: values.host,
port: String(values.port),
timeout: String(values.timeout),
});
const headers = values.apiKey === undefined
? undefined
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
return request<ApiServicePayload>(
`${base}/api/settings/api-service/start?${query}`,
token,
{ headers },
);
}
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
}
export async function enableNanobotFeature(
token: string,
name: string,
options: { instanceId?: string } = {},
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/enable?${query}`,
token,
@@ -402,16 +439,138 @@ export async function enableNanobotFeature(
export async function disableNanobotFeature(
token: string,
name: string,
options: { instanceId?: string } = {},
base: string = "",
): Promise<NanobotFeaturesPayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<NanobotFeaturesPayload>(
`${base}/api/settings/nanobot-features/disable?${query}`,
token,
);
}
export async function fetchPairingRequests(
token: string,
base: string = "",
): Promise<PairingPayload> {
return request<PairingPayload>(
`${base}/api/settings/pairing`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function runPairingAction(
token: string,
action: "approve" | "deny",
code: string,
base: string = "",
): Promise<PairingPayload> {
const query = new URLSearchParams();
query.set("code", code);
return request<PairingPayload>(
`${base}/api/settings/pairing/${action}?${query}`,
token,
);
}
export async function startChannelConnect(
token: string,
channel: "feishu" | "weixin",
options: {
domain?: "feishu" | "lark";
instanceId?: string;
mode?: "replace" | "create";
force?: boolean;
} = {},
base: string = "",
): Promise<ChannelConnectPayload> {
const query = new URLSearchParams();
if (options.domain) query.set("domain", options.domain);
if (options.instanceId) query.set("instance_id", options.instanceId);
if (options.mode) query.set("mode", options.mode);
if (options.force) query.set("force", "true");
const suffix = query.toString();
return request<ChannelConnectPayload>(
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
token,
);
}
export async function pollChannelConnect(
token: string,
channel: "feishu" | "weixin",
sessionId: string,
base: string = "",
): Promise<ChannelConnectPayload> {
const query = new URLSearchParams();
query.set("session_id", sessionId);
return request<ChannelConnectPayload>(
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
token,
);
}
export async function cancelChannelConnect(
token: string,
channel: "feishu" | "weixin",
sessionId: string,
base: string = "",
): Promise<ChannelConnectPayload> {
const query = new URLSearchParams();
query.set("session_id", sessionId);
return request<ChannelConnectPayload>(
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
token,
);
}
export async function configureChannel(
token: string,
name: string,
values: Record<string, string>,
options: { enable?: boolean; instanceId?: string } = {},
base: string = "",
): Promise<ChannelConfigurePayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.enable !== undefined) query.set("enable", String(options.enable));
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<ChannelConfigurePayload>(
`${base}/api/settings/channels/configure?${query}`,
token,
{
headers: {
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
},
},
);
}
export async function validateChannel(
token: string,
name: string,
values: Record<string, string> = {},
options: { instanceId?: string } = {},
base: string = "",
): Promise<ChannelValidationPayload> {
const query = new URLSearchParams();
query.set("name", name);
if (options.instanceId) query.set("instance_id", options.instanceId);
return request<ChannelValidationPayload>(
`${base}/api/settings/channels/validate?${query}`,
token,
{
headers: {
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
},
},
);
}
export async function runCliAppAction(
token: string,
action: "install" | "update" | "uninstall" | "test",
+15
View File
@@ -0,0 +1,15 @@
export function isLoopbackHost(host: string): boolean {
let normalized = host.trim().toLowerCase();
if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (normalized === "localhost" || normalized === "::1") return true;
const octets = normalized.split(".");
return (
octets.length === 4 &&
octets[0] === "127" &&
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
);
}
+132 -1
View File
@@ -332,6 +332,7 @@ export interface RuntimeCapabilities {
export interface ProviderModelInfo {
id: string;
label?: string | null;
description?: string | null;
owned_by?: string | null;
context_window?: number | null;
}
@@ -345,7 +346,7 @@ export interface ProviderModelsPayload {
| "not_configured"
| "missing_api_base"
| "error";
catalog_kind: "official" | "catalog" | "local" | "custom" | "unsupported";
catalog_kind: "builtin" | "official" | "catalog" | "local" | "custom" | "unsupported";
models: ProviderModelInfo[];
model_count: number;
message?: string | null;
@@ -399,6 +400,7 @@ export interface SettingsPayload {
api_base?: string | null;
default_api_base?: string | null;
model_selectable?: boolean;
model_catalog?: ProviderModelsPayload["catalog_kind"];
api_type?: "auto" | "chat_completions" | "responses";
oauth_account?: string | null;
oauth_expires_at?: number | null;
@@ -428,6 +430,17 @@ export interface SettingsPayload {
use_jina_reader: boolean;
};
};
api?: {
host: string;
port: number;
timeout: number;
api_key_hint?: string | null;
};
observability?: {
provider: "langfuse" | string;
configured: boolean;
base_url: string;
};
image_generation: {
enabled: boolean;
provider: string;
@@ -543,6 +556,26 @@ export interface SettingsPayload {
version?: {
current: string;
};
docs?: {
version: string;
base_url: string;
chat_apps_url: string;
latest_url?: string;
};
}
export interface ApiServicePayload {
installed: boolean;
running: boolean;
managed: boolean;
host: string;
port: number;
timeout: number;
api_key_hint?: string | null;
endpoint: string;
command: string;
log_path?: string | null;
last_action?: "started" | "stopped" | string;
}
export interface AppPackageRef {
@@ -638,6 +671,10 @@ export interface NanobotFeatureInfo {
display_name: string;
type: "channel" | "feature" | string;
enabled: boolean;
configured?: boolean;
config_values?: Record<string, string>;
configured_fields?: string[];
instances?: NanobotChannelInstanceInfo[];
installed: boolean;
ready: boolean;
status: "enabled" | "missing_dependency" | "not_enabled" | string;
@@ -645,6 +682,19 @@ export interface NanobotFeatureInfo {
requires_restart: boolean;
}
export interface NanobotChannelInstanceInfo {
id: string;
name: string;
display_name?: string;
avatar_url?: string;
domain?: "feishu" | "lark" | string;
enabled: boolean;
configured: boolean;
app_id?: string;
group_policy?: string;
allow_from?: string[];
}
export interface NanobotFeaturesPayload {
features: NanobotFeatureInfo[];
enabled_count: number;
@@ -656,6 +706,64 @@ export interface NanobotFeaturesPayload {
};
}
export type ChannelSetupStatus =
| "connected"
| "configured"
| "needs_setup"
| "invalid"
| "unsupported"
| string;
export type ChannelValidationCheckStatus = "pass" | "warn" | "fail" | "skipped" | string;
export interface ChannelValidationCheck {
id: string;
label: string;
status: ChannelValidationCheckStatus;
message?: string;
action_url?: string;
}
export interface ChannelIdentity {
name?: string;
workspace?: string;
account?: string;
avatar_url?: string;
}
export interface ChannelValidationPayload {
name: string;
status: ChannelSetupStatus;
checks: ChannelValidationCheck[];
identity?: ChannelIdentity;
missing_fields: string[];
can_enable: boolean;
requires_restart: boolean;
checked_at?: string;
message?: string;
}
export interface PairingRequestInfo {
code: string;
channel: string;
sender_id: string;
created_at_ms?: number | null;
expires_at_ms?: number | null;
expires_in_seconds?: number | null;
}
export interface PairingPayload {
requests: PairingRequestInfo[];
last_action?: {
ok: boolean;
action: "approve" | "deny" | string;
message: string;
code?: string;
channel?: string;
sender_id?: string;
};
}
export interface McpPresetField {
name: string;
label: string;
@@ -725,6 +833,29 @@ export interface McpPresetsPayload {
};
}
export type ChannelConnectStatus = "pending" | "succeeded" | "expired" | "cancelled" | "failed";
export interface ChannelConnectPayload {
session_id: string;
instance_id?: string;
status: ChannelConnectStatus;
message?: string;
qr_url?: string;
domain?: string;
interval_ms?: number;
expires_at_ms?: number;
app_id?: string;
account?: string;
nanobot_features?: NanobotFeaturesPayload;
}
export interface ChannelConfigurePayload {
name: string;
saved: boolean;
saved_keys?: string[];
nanobot_features?: NanobotFeaturesPayload;
}
export interface SettingsUpdate {
model?: string;
provider?: string;
+122
View File
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureChannel,
createModelConfiguration,
deleteSession,
fetchFilePreview,
fetchAutomations,
fetchApiService,
fetchCliApps,
fetchInstalledCliApps,
fetchMcpPresets,
@@ -28,6 +30,11 @@ import {
runCliAppAction,
runMcpPresetAction,
saveCustomMcpServer,
startApiService,
stopApiService,
cancelChannelConnect,
pollChannelConnect,
startChannelConnect,
updateAutomation,
updateSidebarState,
updateImageGenerationSettings,
@@ -37,6 +44,7 @@ import {
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
validateChannel,
} from "@/lib/api";
describe("webui API helpers", () => {
@@ -116,6 +124,82 @@ describe("webui API helpers", () => {
);
});
it("validates channel settings with form values", async () => {
await validateChannel(
"tok",
"slack",
{ "channels.slack.botToken": "xoxb-test" },
{ instanceId: "default" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/validate?name=slack&instance_id=default",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.slack.botToken": "xoxb-test",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
);
});
it("configures channels through the WebSocket HTTP shim", async () => {
await configureChannel(
"tok",
"discord",
{ "channels.discord.token": "saved-secret" },
{ enable: true },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/channels/configure?name=discord&enable=true",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer tok",
"X-Nanobot-Channel-Values": JSON.stringify({
"channels.discord.token": "saved-secret",
}),
}),
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ method: "POST" }),
);
});
it("serializes channel QR connect helpers", async () => {
await startChannelConnect("tok", "weixin", { force: true });
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/start?force=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await pollChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await cancelChannelConnect("tok", "weixin", "session+/=");
expect(fetch).toHaveBeenLastCalledWith(
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes workspace automation actions", async () => {
await runAutomationAction("tok", "disable", "job 1/2");
@@ -478,6 +562,44 @@ describe("webui API helpers", () => {
);
});
it("manages the API service capability", async () => {
await fetchApiService("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
await startApiService(
"tok",
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
},
}),
);
expect(fetch).not.toHaveBeenCalledWith(
expect.stringContaining("secret-token"),
expect.anything(),
);
await stopApiService("tok");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/api-service/stop",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
});
it("reads MCP presets and serializes actions", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
+42
View File
@@ -249,6 +249,8 @@ describe("App layout", () => {
localStorage.removeItem("nanobot-webui.sidebar");
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
localStorage.removeItem("nanobot-webui.restartStartedAt");
localStorage.removeItem("nanobot-webui.restartRoute");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
api_token: "api-tok",
@@ -343,6 +345,35 @@ describe("App layout", () => {
).toBeTruthy();
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
window.history.replaceState(null, "", "/#/new");
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/settings/nanobot-features": {
features: [{
name: "websocket",
display_name: "Websocket",
type: "channel",
enabled: true,
installed: true,
ready: true,
status: "enabled",
install_supported: true,
requires_restart: true,
}],
enabled_count: 1,
},
});
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect((await screen.findAllByRole("heading", { name: "Channels" })).length).toBeGreaterThan(0);
expect(window.location.hash).toBe("#/settings?section=channels");
});
it("opens Skills from the main sidebar", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
@@ -1586,6 +1617,7 @@ describe("App layout", () => {
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Files" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
@@ -1708,6 +1740,16 @@ describe("App layout", () => {
expect(window.location.hash).toBe("#/settings?section=voice");
});
it("falls back to Overview for the retired Files settings URL", async () => {
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
window.history.replaceState(null, "", "/#/settings?section=files");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
});
it("updates the URL hash when switching settings sections", async () => {
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog";
describe("Slack setup manifest", () => {
it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])(
"subscribes to %s",
(event) => expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${event}`),
);
it.each([
"app_mentions:read",
"channels:history",
"channels:read",
"chat:write",
"files:read",
"files:write",
"groups:history",
"groups:read",
"im:history",
"im:write",
"mpim:history",
"reactions:write",
"users:read",
])("requests the %s scope", (scope) => {
expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${scope}`);
});
});
-1
View File
@@ -65,7 +65,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.sections.capabilities",
"settings.sections.apps",
"settings.apps.description",
"settings.apps.filterPlugins",
"settings.apps.caption",
"settings.apps.restartRequired",
"settings.nanobotFeatures.disable",
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { isLoopbackHost } from "@/lib/network";
describe("isLoopbackHost", () => {
it.each(["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"])(
"accepts explicit loopback host %s",
(host) => expect(isLoopbackHost(host)).toBe(true),
);
it.each(["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"])(
"rejects network host %s",
(host) => expect(isLoopbackHost(host)).toBe(false),
);
});
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import {
__clearLogoFallbackCacheForTests,
useLogoFallback,
} from "@/hooks/useLogoFallback";
function TestLogo({ urls }: { urls: string[] }) {
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(urls);
if (!logoUrl) return <span>No logo</span>;
return (
<img
src={logoUrl}
alt="Logo"
onLoad={onLogoLoad}
onError={onLogoError}
/>
);
}
describe("useLogoFallback", () => {
afterEach(() => {
__clearLogoFallbackCacheForTests();
});
it("remembers failed and loaded logo candidates across remounts", () => {
const urls = [
"https://bad.example/favicon.ico",
"https://good.example/favicon.ico",
];
const first = render(<TestLogo urls={urls} />);
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[0]);
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
fireEvent.load(screen.getByRole("img", { name: "Logo" }));
first.unmount();
render(<TestLogo urls={urls} />);
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
});
it("returns no logo once every candidate failed", () => {
const urls = ["https://bad.example/favicon.ico"];
render(<TestLogo urls={urls} />);
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
expect(screen.getByText("No logo")).toBeInTheDocument();
});
});