diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9d5279e5..3056e920 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 --config ` | Update a specific config file | | `nanobot plugins disable --config ` | 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 | diff --git a/docs/configuration.md b/docs/configuration.md index e8b01beb..cae55e6a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 `` 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`. diff --git a/docs/webui.md b/docs/webui.md index 76a9159c..cdfc166c 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -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://: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 { diff --git a/hatch_build.py b/hatch_build.py index 28dbcd09..d4e93f18 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -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 diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 59bb351b..2c7d8955 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -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: diff --git a/nanobot/api/runtime.py b/nanobot/api/runtime.py new file mode 100644 index 00000000..ef062156 --- /dev/null +++ b/nanobot/api/runtime.py @@ -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 diff --git a/nanobot/channels/_feishu_instances.py b/nanobot/channels/_feishu_instances.py new file mode 100644 index 00000000..e6a0a03a --- /dev/null +++ b/nanobot/channels/_feishu_instances.py @@ -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}) diff --git a/nanobot/channels/_feishu_ws.py b/nanobot/channels/_feishu_ws.py new file mode 100644 index 00000000..8be894ba --- /dev/null +++ b/nanobot/channels/_feishu_ws.py @@ -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 diff --git a/nanobot/channels/_setup.py b/nanobot/channels/_setup.py new file mode 100644 index 00000000..cb4a40fc --- /dev/null +++ b/nanobot/channels/_setup.py @@ -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) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index b552f1c4..a01633c8 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -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 diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 34fb7c5f..304101c8 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -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: diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 9e10fe83..d2ed34a4 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -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!\[[^\]]*\]\()" + r"(?Pmxc://[^\s)]+)" + r"(?P(?:\s+[^)]*)?\))" +) +_MXC_IMAGE_SRC_RE = re.compile( + r"(?P\bsrc=)(?P[\"'])(?Pmxc://[^\"']+)(?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: diff --git a/nanobot/channels/registry.py b/nanobot/channels/registry.py index c6ff6073..95f2d6be 100644 --- a/nanobot/channels/registry.py +++ b/nanobot/channels/registry.py @@ -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 ] diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a6ebbe35..8ca3bb23 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -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"), ): diff --git a/nanobot/cli/gateway.py b/nanobot/cli/gateway.py index 63159d03..62624d0f 100644 --- a/nanobot/cli/gateway.py +++ b/nanobot/cli/gateway.py @@ -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, ) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 384f0f5a..2fb47abd 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -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_]*)\}") diff --git a/nanobot/gateway/runtime.py b/nanobot/gateway/runtime.py index bff59f5c..60d562f4 100644 --- a/nanobot/gateway/runtime.py +++ b/nanobot/gateway/runtime.py @@ -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) diff --git a/nanobot/optional_features.py b/nanobot/optional_features.py index 056f3b3a..b1965a34 100644 --- a/nanobot/optional_features.py +++ b/nanobot/optional_features.py @@ -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} ) diff --git a/nanobot/pairing/__init__.py b/nanobot/pairing/__init__.py index 1650500e..2dec720c 100644 --- a/nanobot/pairing/__init__.py +++ b/nanobot/pairing/__init__.py @@ -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", ] diff --git a/nanobot/pairing/store.py b/nanobot/pairing/store.py index 1dff873a..92e75ce3 100644 --- a/nanobot/pairing/store.py +++ b/nanobot/pairing/store.py @@ -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}`." ) diff --git a/nanobot/process_runtime.py b/nanobot/process_runtime.py new file mode 100644 index 00000000..2cc17cfc --- /dev/null +++ b/nanobot/process_runtime.py @@ -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) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 6fbfc2d4..bae927b7 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -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", diff --git a/nanobot/security/network.py b/nanobot/security/network.py index 9cc2e9ca..95523f3a 100644 --- a/nanobot/security/network.py +++ b/nanobot/security/network.py @@ -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 diff --git a/nanobot/skills/update-setup/SKILL.md b/nanobot/skills/update-setup/SKILL.md index 5c5bf0e1..66cc8a51 100644 --- a/nanobot/skills/update-setup/SKILL.md +++ b/nanobot/skills/update-setup/SKILL.md @@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path: **Question 2 — Optional dependencies:** ``` -question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, 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. diff --git a/nanobot/utils/document.py b/nanobot/utils/document.py index 07e102db..fec6ed4b 100644 --- a/nanobot/utils/document.py +++ b/nanobot/utils/document.py @@ -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. diff --git a/nanobot/web/__init__.py b/nanobot/web/__init__.py index 36ee3e93..daacd830 100644 --- a/nanobot/web/__init__.py +++ b/nanobot/web/__init__.py @@ -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``. """ diff --git a/nanobot/webui/build.py b/nanobot/webui/build.py new file mode 100644 index 00000000..d68dce62 --- /dev/null +++ b/nanobot/webui/build.py @@ -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) diff --git a/nanobot/webui/channel_connect.py b/nanobot/webui/channel_connect.py new file mode 100644 index 00000000..10c3eea3 --- /dev/null +++ b/nanobot/webui/channel_connect.py @@ -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.", + } diff --git a/nanobot/webui/channel_validation.py b/nanobot/webui/channel_validation.py new file mode 100644 index 00000000..0358d067 --- /dev/null +++ b/nanobot/webui/channel_validation.py @@ -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}") diff --git a/nanobot/webui/gateway_services.py b/nanobot/webui/gateway_services.py index c565b3a1..bced1b37 100644 --- a/nanobot/webui/gateway_services.py +++ b/nanobot/webui/gateway_services.py @@ -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( diff --git a/nanobot/webui/nanobot_features_api.py b/nanobot/webui/nanobot_features_api.py index 36773c60..e8c7a9e5 100644 --- a/nanobot/webui/nanobot_features_api.py +++ b/nanobot/webui/nanobot_features_api.py @@ -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) diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index b00730ff..59a11e49 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -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 diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 52223520..de76a75d 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -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 diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index b924e941..664108a2 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 07213fa2..88e7a046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index b91b7f4e..9347ab3f 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -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"] == [] diff --git a/tests/channels/test_channel_manager_hot_reload.py b/tests/channels/test_channel_manager_hot_reload.py new file mode 100644 index 00000000..946ce997 --- /dev/null +++ b/tests/channels/test_channel_manager_hot_reload.py @@ -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() diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index f5411503..f2d64666 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -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 diff --git a/tests/channels/test_channel_setup.py b/tests/channels/test_channel_setup.py new file mode 100644 index 00000000..0f7cf9dd --- /dev/null +++ b/tests/channels/test_channel_setup.py @@ -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"}, + ) diff --git a/tests/channels/test_feishu_login.py b/tests/channels/test_feishu_login.py index fc057e67..58f39993 100644 --- a/tests/channels/test_feishu_login.py +++ b/tests/channels/test_feishu_login.py @@ -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" diff --git a/tests/channels/test_feishu_reply.py b/tests/channels/test_feishu_reply.py index 6606739c..ebdb7261 100644 --- a/tests/channels/test_feishu_reply.py +++ b/tests/channels/test_feishu_reply.py @@ -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.""" diff --git a/tests/channels/test_feishu_ws.py b/tests/channels/test_feishu_ws.py new file mode 100644 index 00000000..54992f51 --- /dev/null +++ b/tests/channels/test_feishu_ws.py @@ -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() diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index f6f54f83..dc00af5f 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -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, diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 3d6fbbe5..ffe2d16f 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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=" 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=" 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"]) diff --git a/tests/cli/test_gateway_commands.py b/tests/cli/test_gateway_commands.py index 87f2dae1..df387945 100644 --- a/tests/cli/test_gateway_commands.py +++ b/tests/cli/test_gateway_commands.py @@ -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, diff --git a/tests/gateway/test_api_runtime.py b/tests/gateway/test_api_runtime.py new file mode 100644 index 00000000..77feed78 --- /dev/null +++ b/tests/gateway/test_api_runtime.py @@ -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", + ]] diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index 5643d17b..366888ea 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -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 diff --git a/tests/pairing/test_store.py b/tests/pairing/test_store.py index d25ada71..af38fe70 100644 --- a/tests/pairing/test_store.py +++ b/tests/pairing/test_store.py @@ -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: diff --git a/tests/security/test_security_network.py b/tests/security/test_security_network.py index 09ccbcf1..fc4ea767 100644 --- a/tests/security/test_security_network.py +++ b/tests/security/test_security_network.py @@ -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"): diff --git a/tests/test_document_parsing.py b/tests/test_document_parsing.py index 9b424998..be4e996f 100644 --- a/tests/test_document_parsing.py +++ b/tests/test_document_parsing.py @@ -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" diff --git a/tests/tools/test_read_enhancements.py b/tests/tools/test_read_enhancements.py index 490623d5..7f207e1e 100644 --- a/tests/tools/test_read_enhancements.py +++ b/tests/tools/test_read_enhancements.py @@ -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")) diff --git a/tests/webui/test_build.py b/tests/webui/test_build.py new file mode 100644 index 00000000..fda0b5c3 --- /dev/null +++ b/tests/webui/test_build.py @@ -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] diff --git a/tests/webui/test_channel_connect.py b/tests/webui/test_channel_connect.py new file mode 100644 index 00000000..c6b78a2d --- /dev/null +++ b/tests/webui/test_channel_connect.py @@ -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 diff --git a/tests/webui/test_channel_validation.py b/tests/webui/test_channel_validation.py new file mode 100644 index 00000000..09773c44 --- /dev/null +++ b/tests/webui/test_channel_validation.py @@ -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"] diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 1ad2bec9..3e91ca30 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -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( diff --git a/webui/bun.lock b/webui/bun.lock index 4d0fa5fa..ae22c3ff 100644 --- a/webui/bun.lock +++ b/webui/bun.lock @@ -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=="], } } diff --git a/webui/package.json b/webui/package.json index 3c6fcaff..aad87be7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index c8434f30..870aa7e4 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 = { + 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 ( +
+
+ +
+
+
+

+ {t("app.pairing.title", { defaultValue: "Pair a chat user" })} +

+

+ {t("app.pairing.description", { + defaultValue: "Enter the pairing code shown in the chat.", + })} +

+
+ +
+ + + setValue(formatPairingCodeInput(next))} + /> + +
+ + {matchedRequest + ? t("app.pairing.matched", { + defaultValue: "Matched {{channel}}. Connecting...", + channel: channelLabel(matchedRequest.channel), + }) + : t("app.pairing.expiresInline", { + defaultValue: "Code expires {{expires}}.", + expires, + })} + + {total > 1 ? ( + + {t("app.pairing.queueCount", { + defaultValue: "{{count}} pending", + count: total, + })} + + ) : null} +
+ + {showNoMatch ? ( +

+ {t("app.pairing.noMatch", { + defaultValue: "No pending request matches this code.", + })} +

+ ) : null} + + {error ? ( +

{error}

+ ) : null} +
+
+
+ ); +} + +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 ( +
+ {logoUrl ? ( + + ) : presentation ? ( + + {initials} + + ) : ( + + )} +
+ ); +} + +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(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 ( +
+ {char || " "} +
+ ); + }; + + return ( +
inputRef.current?.focus()} + > + 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" + /> +
+ {slots.slice(0, 4).map((char, index) => renderSlot(char, index))} +
+ {slots.slice(4).map((char, index) => renderSlot(char, index + 4))} +
+
+ ); +} + +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({ 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(null); const [isRestarting, setIsRestarting] = useState(false); + const [pairingRequests, setPairingRequests] = useState([]); + const [pairingBusyCode, setPairingBusyCode] = useState(null); + const [pairingError, setPairingError] = useState(null); + const [snoozedPairingCodes, setSnoozedPairingCodes] = useState>( + () => new Map(), + ); const [runningChatIds, setRunningChatIds] = useState>(() => new Set()); const [updatedChatIds, setUpdatedChatIds] = useState>(readSessionUpdateChatIds); const [workspaces, setWorkspaces] = useState(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(() => { 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}
) : null} + void onPairingAction("approve", code)} + onDismiss={onDismissPairingRequest} + /> ); diff --git a/webui/src/components/CliAppMentionText.tsx b/webui/src/components/CliAppMentionText.tsx index d90df223..84befeb4 100644 --- a/webui/src/components/CliAppMentionText.tsx +++ b/webui/src/components/CliAppMentionText.tsx @@ -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 ( setLogoIndex((index) => index + 1)} + decoding="async" + loading="lazy" + onLoad={onLogoLoad} + onError={onLogoError} /> ) : 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 ( setLogoIndex((index) => index + 1)} + decoding="async" + loading="lazy" + onLoad={onLogoLoad} + onError={onLogoError} /> ) : null} diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 8aa38a22..0a4c9f98 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -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, }; } diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 64f19b2d..d9855a0a 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3,6 +3,7 @@ import { useEffect, forwardRef, useMemo, + useRef, useState, type Dispatch, type FormEvent, @@ -36,6 +37,7 @@ import { Layers, Loader2, LogOut, + MessageCircle, Mic, Moon, PauseCircle, @@ -62,6 +64,17 @@ import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings"; import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap"; +import { ToggleButton } from "@/components/settings/ToggleButton"; +import { + channelDisplayName, + channelMatchesFilter, + channelSearchText, + type ChannelFilter, +} from "@/components/settings/channels/ChannelIdentity"; +import { + ChannelCatalogRow, + ChannelSetupPanel, +} from "@/components/settings/channels/ChannelSetupPanel"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -80,11 +93,13 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { isLoopbackHost } from "@/lib/network"; import { checkVersion, createModelConfiguration, disableNanobotFeature, enableNanobotFeature, + fetchApiService, fetchAutomations, fetchSettings, fetchSettingsUsage, @@ -99,6 +114,8 @@ import { runCliAppAction, runMcpPresetAction, saveCustomMcpServer, + startApiService, + stopApiService, updateAutomation, updateImageGenerationSettings, updateMcpServerTools, @@ -122,6 +139,8 @@ import { import { getHostApi } from "@/lib/runtime"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { fmtDateTime, relativeTime } from "@/lib/format"; +import { useLogoFallback } from "@/hooks/useLogoFallback"; +import { useMediaQuery } from "@/hooks/useMediaQuery"; import { logoFallbackUrls, providerBrand, @@ -131,6 +150,7 @@ import { cn } from "@/lib/utils"; import { shortWorkspacePath } from "@/lib/workspace"; import { useClient } from "@/providers/ClientProvider"; import type { + ApiServicePayload, AutomationsPayload, AutomationUpdatePayload, CliAppInfo, @@ -157,18 +177,18 @@ export type SettingsSectionKey = | "image" | "voice" | "browser" + | "channels" | "apps" | "automations" | "skills" | "runtime" | "advanced"; -type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp"; +type AppsKindFilter = "ready" | "cli" | "mcp"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationSort = "next" | "last" | "updated" | "name"; type AutomationAction = "enable" | "disable" | "delete" | "run"; type AppsCatalogItem = - | { id: string; kind: "nanobot"; feature: NanobotFeatureInfo } | { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "mcp"; preset: McpPresetInfo }; @@ -518,6 +538,7 @@ export function SettingsView({ const [settings, setSettings] = useState(() => initialSettings); const [cliApps, setCliApps] = useState(null); const [nanobotFeatures, setNanobotFeatures] = useState(null); + const featureCatalog = nanobotFeatures?.features ?? []; const [mcpPresets, setMcpPresets] = useState(null); const [automations, setAutomations] = useState(null); const [loading, setLoading] = useState(() => initialSettings === null); @@ -542,21 +563,25 @@ export function SettingsView({ const [imageGenerationSaving, setImageGenerationSaving] = useState(false); const [transcriptionSaving, setTranscriptionSaving] = useState(false); const [networkSafetySaving, setNetworkSafetySaving] = useState(false); + const [apiService, setApiService] = useState(null); + const [apiServiceLoading, setApiServiceLoading] = useState(false); + const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null); + const [apiServiceError, setApiServiceError] = useState(null); const [hostEngineApplying, setHostEngineApplying] = useState(false); const [error, setError] = useState(null); const [activeSection, setActiveSection] = useState(initialSection); const [expandedProvider, setExpandedProvider] = useState(null); const [providerQuery, setProviderQuery] = useState(""); const [appsQuery, setAppsQuery] = useState(""); + const [channelsQuery, setChannelsQuery] = useState(""); const [automationsQuery, setAutomationsQuery] = useState(""); const [automationsFilter, setAutomationsFilter] = useState("all"); const [automationsSort, setAutomationsSort] = useState("next"); const [cliAppsMessage, setCliAppsMessage] = useState(null); const [cliAppsError, setCliAppsError] = useState(null); - const [nanobotFeaturesMessage, setNanobotFeaturesMessage] = useState(null); const [nanobotFeaturesError, setNanobotFeaturesError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); - const [appsKindFilter, setAppsKindFilter] = useState("all"); + const [appsKindFilter, setAppsKindFilter] = useState("ready"); const [mcpMessage, setMcpMessage] = useState(null); const [mcpError, setMcpError] = useState(null); const [automationsError, setAutomationsError] = useState(null); @@ -719,7 +744,7 @@ export function SettingsView({ }, [activeSection, token]); useEffect(() => { - if (activeSection !== "apps") return; + if (!["channels", "models", "browser", "runtime"].includes(activeSection)) return; let cancelled = false; setNanobotFeaturesLoading(true); fetchNanobotFeatures(token) @@ -741,6 +766,28 @@ export function SettingsView({ }; }, [activeSection, token]); + useEffect(() => { + if (activeSection !== "runtime") return; + let cancelled = false; + setApiServiceLoading(true); + fetchApiService(token) + .then((payload) => { + if (!cancelled) { + setApiService(payload); + setApiServiceError(null); + } + }) + .catch((err) => { + if (!cancelled) setApiServiceError((err as Error).message); + }) + .finally(() => { + if (!cancelled) setApiServiceLoading(false); + }); + return () => { + cancelled = true; + }; + }, [activeSection, token]); + useEffect(() => { if (activeSection !== "apps") return; let cancelled = false; @@ -1119,6 +1166,54 @@ export function SettingsView({ } }; + const installCapabilities = async (names: string[]): Promise => { + const missing = names.filter( + (name) => !featureCatalog.find((feature) => feature.name === name)?.installed, + ); + if (!missing.length) return true; + setNanobotFeatureAction(`enable:${names.join("+")}`); + setNanobotFeaturesError(null); + try { + let latest = nanobotFeatures; + for (const name of missing) { + latest = await enableNanobotFeature(token, name); + if (latest.requires_restart) { + setPendingRestartSections((prev) => ({ ...prev, runtime: true })); + } + } + if (latest) setNanobotFeatures(latest); + return true; + } catch (err) { + setNanobotFeaturesError((err as Error).message); + return false; + } finally { + setNanobotFeatureAction(null); + } + }; + + const handleApiServiceAction = async ( + action: "start" | "stop", + values?: { host: string; port: number; timeout: number; apiKey?: string }, + ) => { + if (apiServiceAction) return; + setApiServiceAction(action); + setApiServiceError(null); + try { + const payload = action === "start" + ? await startApiService(token, values!) + : await stopApiService(token); + setApiService(payload); + const refreshed = await fetchNanobotFeatures(token); + setNanobotFeatures(refreshed); + const nextSettings = await fetchSettings(token); + applyPayload(nextSettings); + } catch (err) { + setApiServiceError((err as Error).message); + } finally { + setApiServiceAction(null); + } + }; + const saveProvider = async (providerName: string) => { if (providerSaving) return; const provider = settings?.providers.find((item) => item.name === providerName); @@ -1133,6 +1228,12 @@ export function SettingsView({ } setProviderSaving(providerName); try { + const supportName = providerName === "bedrock" + ? "bedrock" + : providerName === "azure_openai" + ? "azure" + : null; + if (supportName && !(await installCapabilities([supportName]))) return; const payload = await updateProviderSettings(token, { provider: providerName, apiKey: apiKey || undefined, @@ -1202,6 +1303,7 @@ export function SettingsView({ setWebSearchSaving(true); try { + if (provider.name === "olostep" && !(await installCapabilities(["olostep"]))) return; const webFetchRestartRequired = (webSearchForm.useJinaReader ?? settings.web.fetch.use_jina_reader) !== settings.web.fetch.use_jina_reader; @@ -1341,9 +1443,8 @@ export function SettingsView({ name: string, confirmed = false, ) => { - const feature = nanobotFeatures?.features.find((item) => item.name === name); + const feature = featureCatalog.find((item) => item.name === name); if (action === "enable" && !confirmed && feature && !feature.installed && feature.install_supported) { - setNanobotFeaturesMessage(null); setNanobotFeaturesError(null); setNanobotFeatureConfirm(feature); return; @@ -1351,18 +1452,13 @@ export function SettingsView({ const key = `${action}:${name}`; setNanobotFeatureAction(key); setNanobotFeatureConfirm(null); - setNanobotFeaturesMessage(null); setNanobotFeaturesError(null); try { const payload = action === "enable" ? await enableNanobotFeature(token, name) : await disableNanobotFeature(token, name); setNanobotFeatures(payload); - setNanobotFeaturesMessage(payload.last_action?.message ?? null); - if ( - payload.requires_restart || - payload.features.some((feature) => feature.name === name && feature.requires_restart) - ) { + if (payload.requires_restart) { setPendingRestartSections((prev) => ({ ...prev, runtime: true })); } } catch (err) { @@ -1554,6 +1650,9 @@ export function SettingsView({ /> runProviderOAuth(provider, "login")} onProviderOAuthLogout={(provider) => runProviderOAuth(provider, "logout")} onResetProviderDraft={resetProviderDraft} - imageProviderRestartPending={pendingRestartSections.image} + imageProviderRestartPending={pendingRestartSections.image || pendingRestartSections.runtime} onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} /> @@ -1640,26 +1739,46 @@ export function SettingsView({ onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} requiresRestartPending={pendingRestartSections.browser} + olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")} + olostepInstalling={nanobotFeatureAction === "enable:olostep"} + capabilityError={nanobotFeaturesError} + /> + ); + case "channels": + return ( + { + setNanobotFeaturesError(null); + }} + onRestart={restartViaSettingsSurface} + isRestarting={isRestarting || hostEngineApplying} /> ); case "apps": return ( { setCliAppsMessage(null); setCliAppsError(null); - setNanobotFeaturesMessage(null); - setNanobotFeaturesError(null); setMcpMessage(null); setMcpError(null); }} @@ -1732,6 +1848,16 @@ export function SettingsView({ onRestart={restartViaSettingsSurface} isRestarting={isRestarting || hostEngineApplying} requiresRestartPending={pendingRestartSections.runtime} + apiService={apiService} + apiServiceLoading={apiServiceLoading} + apiServiceAction={apiServiceAction} + apiServiceError={apiServiceError} + langfuseFeature={featureCatalog.find((feature) => feature.name === "langfuse")} + capabilitiesLoading={nanobotFeaturesLoading} + capabilityAction={nanobotFeatureAction} + capabilityError={nanobotFeaturesError} + onApiServiceAction={handleApiServiceAction} + onInstallCapability={(name) => void installCapabilities([name])} /> ); case "advanced": @@ -1756,7 +1882,7 @@ export function SettingsView({ return (
-
+
@@ -1850,7 +1983,13 @@ export function SettingsView({ ) : settings ? ( -
+
{error ? (
{error} @@ -1872,6 +2011,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal { key: "image", icon: ImageIcon, fallback: "Image" }, { key: "voice", icon: Mic, fallback: "Voice" }, { key: "browser", icon: Globe2, fallback: "Web" }, + { key: "channels", icon: MessageCircle, fallback: "Channels" }, { key: "runtime", icon: Server, fallback: "System" }, { key: "advanced", icon: ShieldCheck, fallback: "Security" }, ]; @@ -1901,19 +2041,19 @@ function SettingsSidebar({ return (