feat(webui): add guided setup flows
* feat(channels): add guided setup flows * test(channels): preserve setup config values * fix(channels): reflect saved setup state * refactor(channels): simplify setup state metadata * fix(channels): harden setup lifecycle * refactor(channels): centralize setup contracts * fix(channels): route setup actions through webui shim * fix(channels): adapt settings for compact screens * fix(models): preserve default preset display * feat(models): add curated Codex catalog * fix(webui): stop attached gateway on interrupt * fix(webui): simplify apps catalog * docs(webui): clarify apps and runtime features * feat(settings): add guided capability setup * fix(webui): harden setup and managed services * test: keep managed runtime checks portable * test: scope POSIX runtime coverage * fix(webui): simplify file settings * feat(files): bundle document reading * fix(webui): harden setup request boundaries * fix(webui): prevent channel setup status squeeze * fix(settings): group provider compatibility aliases * refactor(settings): remove redundant setup surfaces * fix(webui): harden guided setup lifecycle * fix(webui): preserve channel setup compatibility
This commit is contained in:
@@ -255,6 +255,19 @@ without hand-editing JSON. Enabling may install the support package first.
|
||||
Disabling is for channels such as Telegram, Matrix, or Slack; it keeps your
|
||||
saved settings and turns the channel off.
|
||||
|
||||
The `plugins` command name is retained for compatibility, but these entries are
|
||||
nanobot runtime support packages, not the user-invokable tools shown in WebUI
|
||||
Apps. They cannot be attached to a chat turn with `@`.
|
||||
|
||||
| Feature name | What it enables |
|
||||
|---|---|
|
||||
| `api` | Dependencies required by the OpenAI-compatible `nanobot serve` process |
|
||||
| `azure` | Azure identity support for Azure-hosted models |
|
||||
| `bedrock` | AWS Bedrock model provider support |
|
||||
| `langfuse` | Langfuse tracing support for OpenAI-compatible providers |
|
||||
| `olostep` | Olostep web search provider support |
|
||||
| A channel name such as `telegram` or `slack` | The connector package and saved channel enablement |
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `nanobot plugins list` | Show available channels and optional capabilities |
|
||||
@@ -265,6 +278,10 @@ saved settings and turns the channel off.
|
||||
| `nanobot plugins enable <name> --config <path>` | Update a specific config file |
|
||||
| `nanobot plugins disable <channel> --config <path>` | Turn off a channel in a specific config file |
|
||||
|
||||
Document and PDF reading are included in the standard installation. The old
|
||||
`nanobot plugins enable documents` and `nanobot plugins enable pdf` commands
|
||||
remain accepted as no-op compatibility aliases.
|
||||
|
||||
## Provider OAuth
|
||||
|
||||
| Command | Description |
|
||||
|
||||
@@ -213,7 +213,7 @@ nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK
|
||||
Install the optional package in the same Python environment that runs nanobot:
|
||||
|
||||
```bash
|
||||
python -m pip install langfuse
|
||||
nanobot plugins enable langfuse
|
||||
```
|
||||
|
||||
Set Langfuse credentials before starting `nanobot agent`, `nanobot gateway`, or `nanobot serve`:
|
||||
@@ -1541,7 +1541,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
|
||||
| `sendProgress` | `true` | Stream agent's text progress to the channel |
|
||||
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
|
||||
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix / Mattermost keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
|
||||
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Install parser dependencies with `nanobot plugins enable documents`. If you used document parsing before those parsers became optional, run that command after upgrading. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
|
||||
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. PDF, DOCX, XLSX, and PPTX readers are included in the standard installation. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
|
||||
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
|
||||
|
||||
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
|
||||
|
||||
+20
-19
@@ -115,21 +115,22 @@ for provider setup and output behavior.
|
||||
|
||||
## Apps
|
||||
|
||||
Open Apps from the sidebar or settings navigation to manage integrations that
|
||||
nanobot can call from a chat. Nanobot features can enable built-in channels and
|
||||
optional capabilities such as `bedrock` or `documents`. CLI Apps install local
|
||||
adapters that nanobot runs on your machine; they do not modify the native apps
|
||||
themselves. MCP presets add predefined MCP server configurations.
|
||||
Open Apps from the sidebar to manage tools that nanobot can attach to a chat
|
||||
turn. The default **Ready** view shows only tools that can be used immediately:
|
||||
|
||||
Enabling a Nanobot feature may install Python packages into the environment
|
||||
running nanobot. By default, the WebUI can install missing packages only when
|
||||
you open it on the same machine as nanobot. If you open the WebUI from another
|
||||
device, a domain name, a tunnel, or a reverse proxy, package install is blocked
|
||||
unless you explicitly allow it with `tools.webuiAllowRemotePackageInstall`.
|
||||
- **Apps** are local command-line adapters that nanobot runs on your machine.
|
||||
Installing an adapter does not modify the native desktop or web app it
|
||||
connects to.
|
||||
- **Integrations** are MCP servers. Presets provide known configurations, and
|
||||
the custom integration panel accepts stdio, HTTP, and SSE servers.
|
||||
|
||||
Optional feature installs use your existing pip download settings. If PyPI is
|
||||
slow or unavailable from your network, configure pip or set `PIP_INDEX_URL`
|
||||
before starting nanobot.
|
||||
Apps intentionally does not list nanobot runtime support packages such as
|
||||
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
|
||||
are not tools that can be attached to a turn with `@`. Manage them from
|
||||
**System**, **Models**, or **Web**. PDF and common Office document readers are
|
||||
included in nanobot and activate automatically when a file is attached. The
|
||||
equivalent CLI for optional integrations remains `nanobot plugins`. See
|
||||
[`cli-reference.md`](./cli-reference.md#optional-features).
|
||||
|
||||
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
|
||||
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
|
||||
@@ -137,8 +138,8 @@ extraction tools without requiring an API key. This does not replace nanobot's
|
||||
built-in web search provider; mention the Firecrawl MCP preset with `@` when a
|
||||
turn needs Firecrawl's richer web data tools.
|
||||
|
||||
After an App or MCP preset is available, mention it from the composer with `@`
|
||||
to attach that capability to the next message.
|
||||
After an App or integration is available, mention it from the composer with
|
||||
`@` to attach that tool to the next message.
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -226,10 +227,10 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||
form.
|
||||
|
||||
Remote WebUI clients can view Apps and toggle already-installed features with a
|
||||
valid token, but they cannot install missing Python packages by default. To allow
|
||||
trusted remote admins to install optional feature dependencies from the WebUI,
|
||||
opt in explicitly:
|
||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||
install missing nanobot support packages, such as adding a channel dependency,
|
||||
are blocked by default. To let trusted remote administrators change the Python
|
||||
environment through the WebUI, opt in explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
+32
-30
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Shared channel setup contract for configuration, display, and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
|
||||
RouteFieldType = str | tuple[str, set[str]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelFieldSpec:
|
||||
"""One channel field exposed through the settings contract."""
|
||||
|
||||
kind: FieldKind = "string"
|
||||
choices: frozenset[str] = frozenset()
|
||||
writable: bool = True
|
||||
snapshot: bool = True
|
||||
|
||||
@property
|
||||
def route_type(self) -> RouteFieldType:
|
||||
if self.kind == "enum":
|
||||
return ("enum", set(self.choices))
|
||||
return self.kind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetupRequirement:
|
||||
"""A requirement satisfied by any one complete field group."""
|
||||
|
||||
alternatives: tuple[tuple[str, ...], ...]
|
||||
|
||||
def is_satisfied(self, values: Any) -> bool:
|
||||
return any(
|
||||
all(channel_value_present(channel_field_value(values, field)) for field in group)
|
||||
for group in self.alternatives
|
||||
)
|
||||
|
||||
@property
|
||||
def simple_field(self) -> str | None:
|
||||
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
|
||||
return self.alternatives[0][0]
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelSetupSpec:
|
||||
"""Save, display, and validation contract for one channel."""
|
||||
|
||||
fields: dict[str, ChannelFieldSpec]
|
||||
required: tuple[SetupRequirement, ...] = ()
|
||||
official_url: str | None = None
|
||||
|
||||
@property
|
||||
def secrets(self) -> frozenset[str]:
|
||||
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
|
||||
|
||||
@property
|
||||
def snapshot_fields(self) -> tuple[str, ...]:
|
||||
return tuple(name for name, field in self.fields.items() if field.snapshot)
|
||||
|
||||
@property
|
||||
def route_field_types(self) -> dict[str, RouteFieldType]:
|
||||
return {
|
||||
name: field.route_type
|
||||
for name, field in self.fields.items()
|
||||
if field.writable
|
||||
}
|
||||
|
||||
@property
|
||||
def simple_required_fields(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
field
|
||||
for requirement in self.required
|
||||
if (field := requirement.simple_field) is not None
|
||||
)
|
||||
|
||||
def is_configured(self, values: Any) -> bool:
|
||||
return bool(self.required) and all(
|
||||
requirement.is_satisfied(values) for requirement in self.required
|
||||
)
|
||||
|
||||
|
||||
def _field(
|
||||
kind: FieldKind = "string",
|
||||
*,
|
||||
choices: set[str] | None = None,
|
||||
writable: bool = True,
|
||||
snapshot: bool = True,
|
||||
) -> ChannelFieldSpec:
|
||||
return ChannelFieldSpec(
|
||||
kind=kind,
|
||||
choices=frozenset(choices or ()),
|
||||
writable=writable,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _required(field: str) -> SetupRequirement:
|
||||
return SetupRequirement(((field,),))
|
||||
|
||||
|
||||
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
|
||||
return SetupRequirement(alternatives)
|
||||
|
||||
|
||||
_GROUP_POLICIES = {"mention", "open", "allowlist"}
|
||||
_DIRECT_GROUP_POLICIES = {"mention", "open"}
|
||||
|
||||
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
|
||||
"websocket": ChannelSetupSpec(
|
||||
fields={},
|
||||
official_url="http://127.0.0.1:8765",
|
||||
),
|
||||
"telegram": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://t.me/BotFather",
|
||||
),
|
||||
"slack": ChannelSetupSpec(
|
||||
fields={
|
||||
"appToken": _field("secret"),
|
||||
"botToken": _field("secret"),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("appToken"), _required("botToken")),
|
||||
official_url="https://api.slack.com/apps",
|
||||
),
|
||||
"discord": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"allowChannels": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://discord.com/developers/applications",
|
||||
),
|
||||
"email": ChannelSetupSpec(
|
||||
fields={
|
||||
"consentGranted": _field("bool"),
|
||||
"imapHost": _field(),
|
||||
"imapPort": _field("int"),
|
||||
"imapUsername": _field(),
|
||||
"imapPassword": _field("secret"),
|
||||
"smtpHost": _field(),
|
||||
"smtpPort": _field("int"),
|
||||
"smtpUsername": _field(),
|
||||
"smtpPassword": _field("secret"),
|
||||
"fromAddress": _field(),
|
||||
"pollIntervalSeconds": _field("int"),
|
||||
"allowFrom": _field("list"),
|
||||
"verifyDkim": _field("bool"),
|
||||
"verifySpf": _field("bool"),
|
||||
},
|
||||
required=tuple(
|
||||
_required(field)
|
||||
for field in (
|
||||
"consentGranted",
|
||||
"imapHost",
|
||||
"imapUsername",
|
||||
"imapPassword",
|
||||
"smtpHost",
|
||||
"smtpUsername",
|
||||
"smtpPassword",
|
||||
)
|
||||
),
|
||||
official_url="https://support.google.com/accounts/answer/185833",
|
||||
),
|
||||
"matrix": ChannelSetupSpec(
|
||||
fields={
|
||||
"homeserver": _field(),
|
||||
"userId": _field(),
|
||||
"password": _field("secret"),
|
||||
"accessToken": _field("secret"),
|
||||
"deviceId": _field(),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
"allowFrom": _field("list", writable=False),
|
||||
},
|
||||
required=(
|
||||
_required("homeserver"),
|
||||
_required("userId"),
|
||||
_one_of(("password",), ("accessToken", "deviceId")),
|
||||
),
|
||||
official_url="https://matrix.org/ecosystem/clients/",
|
||||
),
|
||||
"mattermost": ChannelSetupSpec(
|
||||
fields={
|
||||
"serverUrl": _field(),
|
||||
"token": _field("secret"),
|
||||
"teamId": _field(),
|
||||
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("serverUrl"), _required("token")),
|
||||
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
|
||||
),
|
||||
"whatsapp": ChannelSetupSpec(
|
||||
fields={
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
|
||||
"databasePath": _field(writable=False, snapshot=False),
|
||||
},
|
||||
official_url="https://faq.whatsapp.com/",
|
||||
),
|
||||
"dingtalk": ChannelSetupSpec(
|
||||
fields={
|
||||
"clientId": _field(),
|
||||
"clientSecret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("clientId"), _required("clientSecret")),
|
||||
official_url="https://open.dingtalk.com/",
|
||||
),
|
||||
"wecom": ChannelSetupSpec(
|
||||
fields={
|
||||
"botId": _field(),
|
||||
"secret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("botId"), _required("secret")),
|
||||
official_url="https://developer.work.weixin.qq.com/",
|
||||
),
|
||||
"weixin": ChannelSetupSpec(
|
||||
fields={
|
||||
"token": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("token"),),
|
||||
official_url="https://weixin.qq.com/",
|
||||
),
|
||||
"qq": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(),
|
||||
"secret": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"msgFormat": _field("enum", choices={"plain", "markdown"}),
|
||||
},
|
||||
required=(_required("appId"), _required("secret")),
|
||||
official_url="https://q.qq.com/",
|
||||
),
|
||||
"signal": ChannelSetupSpec(
|
||||
fields={
|
||||
"phoneNumber": _field(),
|
||||
"daemonHost": _field(),
|
||||
"daemonPort": _field("int"),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"dm.allowFrom": _field("list"),
|
||||
"group.allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("phoneNumber"),),
|
||||
official_url="https://github.com/bbernhard/signal-cli-rest-api",
|
||||
),
|
||||
"msteams": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(),
|
||||
"appPassword": _field("secret"),
|
||||
"tenantId": _field(),
|
||||
"path": _field(),
|
||||
"allowFrom": _field("list"),
|
||||
},
|
||||
required=(_required("appId"), _required("appPassword")),
|
||||
official_url="https://dev.teams.microsoft.com/apps",
|
||||
),
|
||||
"napcat": ChannelSetupSpec(
|
||||
fields={
|
||||
"wsUrl": _field(),
|
||||
"accessToken": _field("secret"),
|
||||
"allowFrom": _field("list"),
|
||||
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
|
||||
},
|
||||
required=(_required("wsUrl"),),
|
||||
official_url="https://napneko.github.io/",
|
||||
),
|
||||
"feishu": ChannelSetupSpec(
|
||||
fields={
|
||||
"appId": _field(snapshot=False),
|
||||
"appSecret": _field("secret", snapshot=False),
|
||||
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
|
||||
"groupPolicy": _field(
|
||||
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
|
||||
),
|
||||
"allowFrom": _field("list", snapshot=False),
|
||||
"topicIsolation": _field("bool", snapshot=False),
|
||||
},
|
||||
required=(_required("appId"), _required("appSecret")),
|
||||
official_url="https://open.feishu.cn/app",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
|
||||
return CHANNEL_SETUP_SPECS.get(name)
|
||||
|
||||
|
||||
def channel_field_value(values: Any, field_path: str) -> Any:
|
||||
current = values
|
||||
for part in field_path.split("."):
|
||||
candidates = (part, _camel_to_snake(part))
|
||||
if isinstance(current, dict):
|
||||
for candidate in candidates:
|
||||
if candidate in current:
|
||||
current = current[candidate]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
continue
|
||||
for candidate in candidates:
|
||||
if hasattr(current, candidate):
|
||||
current = getattr(current, candidate)
|
||||
break
|
||||
else:
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def channel_value_present(value: Any) -> bool:
|
||||
return value not in (None, "", [], {})
|
||||
|
||||
|
||||
def stringify_channel_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(item) for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _camel_to_snake(value: str) -> str:
|
||||
chars: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper():
|
||||
if chars:
|
||||
chars.append("_")
|
||||
chars.append(char.lower())
|
||||
else:
|
||||
chars.append(char)
|
||||
return "".join(chars)
|
||||
+414
-69
@@ -13,6 +13,7 @@ import uuid
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
@@ -24,10 +25,19 @@ from rich.text import Text
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import ProgressEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels._feishu_instances import (
|
||||
DEFAULT_INSTANCE_ID,
|
||||
feishu_instance_specs,
|
||||
runtime_channel_name,
|
||||
update_feishu_instance_preserving_shape,
|
||||
upsert_feishu_instance,
|
||||
)
|
||||
from nanobot.channels._feishu_ws import get_feishu_ws_runner
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.command.router import normalize_command_text
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.pairing import clear_channel
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.logging_bridge import redirect_lib_logging
|
||||
|
||||
@@ -38,6 +48,10 @@ FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||
_LOGIN_CONSOLE = Console()
|
||||
|
||||
|
||||
def _identity_timestamp() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _load_lark_runtime() -> tuple[Any, str, str]:
|
||||
"""Import the heavy Feishu SDK lazily.
|
||||
|
||||
@@ -68,6 +82,58 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
|
||||
|
||||
return lark, FEISHU_DOMAIN, LARK_DOMAIN
|
||||
|
||||
|
||||
def fetch_feishu_app_identity(
|
||||
app_id: str,
|
||||
app_secret: str,
|
||||
domain: str = "feishu",
|
||||
) -> dict[str, str]:
|
||||
"""Fetch the user-facing Feishu/Lark app identity for display.
|
||||
|
||||
This is best-effort metadata for WebUI presentation. Callers should treat
|
||||
an empty result as a normal fallback path.
|
||||
"""
|
||||
if not FEISHU_AVAILABLE or not app_id or not app_secret:
|
||||
return {}
|
||||
|
||||
try:
|
||||
lark, feishu_domain, lark_domain = _load_lark_runtime()
|
||||
from lark_oapi.api.application.v6.model.get_application_request import (
|
||||
GetApplicationRequest,
|
||||
)
|
||||
|
||||
sdk_domain = lark_domain if domain == "lark" else feishu_domain
|
||||
client = (
|
||||
lark.Client.builder()
|
||||
.app_id(app_id)
|
||||
.app_secret(app_secret)
|
||||
.domain(sdk_domain)
|
||||
.timeout(5)
|
||||
.build()
|
||||
)
|
||||
request = GetApplicationRequest.builder().app_id(app_id).lang("zh_cn").build()
|
||||
response = client.application.v6.application.get(request)
|
||||
if hasattr(response, "success") and not response.success():
|
||||
return {}
|
||||
|
||||
app = getattr(getattr(response, "data", None), "app", None)
|
||||
if app is None:
|
||||
return {}
|
||||
|
||||
identity: dict[str, str] = {}
|
||||
display_name = str(getattr(app, "app_name", "") or "").strip()
|
||||
avatar_url = str(getattr(app, "avatar_url", "") or "").strip()
|
||||
if display_name:
|
||||
identity["displayName"] = display_name
|
||||
if avatar_url:
|
||||
identity["avatarUrl"] = avatar_url
|
||||
if identity:
|
||||
identity["identityFetchedAt"] = _identity_timestamp()
|
||||
return identity
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# Message type display mapping
|
||||
MSG_TYPE_MAP = {
|
||||
"image": "[image]",
|
||||
@@ -343,6 +409,9 @@ def _extract_post_text(content_json: dict) -> str:
|
||||
class FeishuConfig(Base):
|
||||
"""Feishu/Lark channel configuration using WebSocket long connection."""
|
||||
|
||||
instance_id: str = DEFAULT_INSTANCE_ID
|
||||
name: str = "nanobot"
|
||||
identity_key: str = ""
|
||||
enabled: bool = False
|
||||
app_id: str = ""
|
||||
app_secret: str = ""
|
||||
@@ -448,39 +517,24 @@ def _poll_registration(
|
||||
"""
|
||||
deadline = time.monotonic() + expire_in
|
||||
current_domain = domain
|
||||
poll_count = 0
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
base_url = _accounts_base_url(current_domain)
|
||||
try:
|
||||
res = _post_registration(base_url, {
|
||||
"action": "poll",
|
||||
"device_code": device_code,
|
||||
"tp": "ob_app",
|
||||
})
|
||||
res = poll_registration_once(device_code=device_code, domain=current_domain)
|
||||
except Exception:
|
||||
time.sleep(interval)
|
||||
continue
|
||||
|
||||
poll_count += 1
|
||||
current_domain = res.get("domain", current_domain)
|
||||
|
||||
# Domain auto-detection: if the user's tenant is on Lark, switch automatically
|
||||
user_info = res.get("user_info") or {}
|
||||
tenant_brand = user_info.get("tenant_brand")
|
||||
if tenant_brand == "lark":
|
||||
current_domain = "lark"
|
||||
|
||||
# Success
|
||||
if res.get("client_id") and res.get("client_secret"):
|
||||
if res.get("status") == "succeeded":
|
||||
return {
|
||||
"app_id": res["client_id"],
|
||||
"app_secret": res["client_secret"],
|
||||
"domain": current_domain,
|
||||
"app_id": res["app_id"],
|
||||
"app_secret": res["app_secret"],
|
||||
"domain": res.get("domain", current_domain),
|
||||
}
|
||||
|
||||
# Terminal errors
|
||||
error = res.get("error", "")
|
||||
if error in ("access_denied", "expired_token"):
|
||||
if res.get("status") == "failed":
|
||||
_LOGIN_CONSOLE.print("[yellow]Authorization was cancelled or expired.[/yellow]")
|
||||
return None
|
||||
|
||||
@@ -491,6 +545,230 @@ def _poll_registration(
|
||||
return None
|
||||
|
||||
|
||||
def poll_registration_once(
|
||||
*,
|
||||
device_code: str,
|
||||
domain: str = "feishu",
|
||||
) -> dict:
|
||||
"""Poll the Feishu/Lark device-code flow once.
|
||||
|
||||
This non-blocking shape is used by WebUI. The CLI keeps using
|
||||
``_poll_registration`` to wait in the terminal.
|
||||
"""
|
||||
current_domain = domain
|
||||
base_url = _accounts_base_url(current_domain)
|
||||
res = _post_registration(base_url, {
|
||||
"action": "poll",
|
||||
"device_code": device_code,
|
||||
"tp": "ob_app",
|
||||
})
|
||||
|
||||
user_info = res.get("user_info") or {}
|
||||
tenant_brand = user_info.get("tenant_brand")
|
||||
if tenant_brand == "lark":
|
||||
current_domain = "lark"
|
||||
|
||||
if res.get("client_id") and res.get("client_secret"):
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"app_id": res["client_id"],
|
||||
"app_secret": res["client_secret"],
|
||||
"domain": current_domain,
|
||||
}
|
||||
|
||||
error = res.get("error", "")
|
||||
if error in ("access_denied", "expired_token"):
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error,
|
||||
"domain": current_domain,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "pending",
|
||||
"domain": current_domain,
|
||||
}
|
||||
|
||||
|
||||
def _feishu_app_identity_key(app_id: str, domain: str) -> str:
|
||||
normalized_app_id = app_id.strip()
|
||||
normalized_domain = "lark" if domain.strip().lower() == "lark" else "feishu"
|
||||
return f"{normalized_domain}:{normalized_app_id}" if normalized_app_id else ""
|
||||
|
||||
|
||||
def _saved_feishu_instance_identity_key(
|
||||
feishu_cfg: Any,
|
||||
defaults: dict[str, Any],
|
||||
instance_id: str,
|
||||
) -> str:
|
||||
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
||||
if spec.instance_id == instance_id:
|
||||
return _feishu_app_identity_key(
|
||||
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
|
||||
str(spec.config.get("domain") or "feishu"),
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def sync_saved_feishu_identity_boundary(
|
||||
*,
|
||||
instance_id: str,
|
||||
app_id: str,
|
||||
domain: str,
|
||||
) -> bool:
|
||||
"""Persist the Feishu app identity marker and clear access if it changed.
|
||||
|
||||
WebUI connect normally handles this at save time. This startup check catches
|
||||
manual config edits so approved users do not accidentally carry over to a
|
||||
different Feishu/Lark app in the same local instance slot.
|
||||
"""
|
||||
current_identity_key = _feishu_app_identity_key(app_id, domain)
|
||||
if not current_identity_key:
|
||||
return False
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
|
||||
full_config = load_config()
|
||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
||||
if not isinstance(feishu_cfg, dict):
|
||||
feishu_cfg = {}
|
||||
|
||||
defaults = FeishuChannel.default_config()
|
||||
previous_identity_key = ""
|
||||
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
||||
if spec.instance_id == instance_id:
|
||||
previous_identity_key = str(
|
||||
spec.config.get("identityKey") or spec.config.get("identity_key") or ""
|
||||
)
|
||||
break
|
||||
|
||||
access_cleared = bool(previous_identity_key and previous_identity_key != current_identity_key)
|
||||
values: dict[str, Any] = {"identityKey": current_identity_key}
|
||||
if access_cleared:
|
||||
values["allowFrom"] = []
|
||||
values["allow_from"] = []
|
||||
clear_channel(runtime_channel_name("feishu", instance_id))
|
||||
|
||||
if not previous_identity_key or access_cleared:
|
||||
feishu_cfg = update_feishu_instance_preserving_shape(
|
||||
feishu_cfg,
|
||||
defaults,
|
||||
instance_id,
|
||||
values,
|
||||
)
|
||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
||||
save_config(full_config)
|
||||
|
||||
return access_cleared
|
||||
|
||||
|
||||
def save_registration_result(
|
||||
result: dict,
|
||||
*,
|
||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
"""Persist a successful Feishu/Lark registration result to config.json."""
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
|
||||
full_config = load_config()
|
||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
||||
if not isinstance(feishu_cfg, dict):
|
||||
feishu_cfg = {}
|
||||
defaults = FeishuChannel.default_config()
|
||||
app_id = str(result["app_id"]).strip()
|
||||
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
|
||||
domain = "lark" if domain == "lark" else "feishu"
|
||||
previous_identity_key = _saved_feishu_instance_identity_key(feishu_cfg, defaults, instance_id)
|
||||
next_identity_key = _feishu_app_identity_key(app_id, domain)
|
||||
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
|
||||
identity: dict[str, str] = {}
|
||||
with suppress(Exception):
|
||||
identity = fetch_feishu_app_identity(
|
||||
app_id,
|
||||
str(result["app_secret"]),
|
||||
domain,
|
||||
)
|
||||
values = {
|
||||
"name": name or ("nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"),
|
||||
"appId": app_id,
|
||||
"appSecret": result["app_secret"],
|
||||
"domain": domain,
|
||||
"identityKey": next_identity_key,
|
||||
"enabled": True,
|
||||
**identity,
|
||||
}
|
||||
if identity_changed:
|
||||
values["allowFrom"] = []
|
||||
values["allow_from"] = []
|
||||
clear_channel(runtime_channel_name("feishu", instance_id))
|
||||
feishu_cfg = upsert_feishu_instance(
|
||||
feishu_cfg,
|
||||
defaults,
|
||||
instance_id,
|
||||
values,
|
||||
)
|
||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
||||
save_config(full_config)
|
||||
|
||||
|
||||
def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
||||
"""Backfill missing Feishu assistant display identity in saved config.
|
||||
|
||||
Existing users may already have working App ID/Secret credentials from
|
||||
older builds. Fetch identity only when an instance has credentials but no
|
||||
identity metadata at all, then persist the attempt so Settings does not hit
|
||||
Feishu on every render.
|
||||
"""
|
||||
if not FEISHU_AVAILABLE:
|
||||
return False
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
|
||||
full_config = config or load_config()
|
||||
feishu_cfg = getattr(full_config.channels, "feishu", None)
|
||||
defaults = FeishuChannel.default_config()
|
||||
specs = feishu_instance_specs(feishu_cfg, defaults)
|
||||
updated = False
|
||||
|
||||
for spec in specs:
|
||||
instance = spec.config
|
||||
if (
|
||||
instance.get("displayName")
|
||||
or instance.get("avatarUrl")
|
||||
or instance.get("identityFetchedAt")
|
||||
):
|
||||
continue
|
||||
|
||||
app_id = str(instance.get("appId") or instance.get("app_id") or "").strip()
|
||||
app_secret = str(instance.get("appSecret") or instance.get("app_secret") or "").strip()
|
||||
if not app_id or not app_secret:
|
||||
continue
|
||||
|
||||
identity = fetch_feishu_app_identity(
|
||||
app_id,
|
||||
app_secret,
|
||||
str(instance.get("domain") or "feishu"),
|
||||
)
|
||||
if not identity:
|
||||
identity = {"identityFetchedAt": _identity_timestamp()}
|
||||
|
||||
feishu_cfg = update_feishu_instance_preserving_shape(
|
||||
feishu_cfg,
|
||||
defaults,
|
||||
spec.instance_id,
|
||||
identity,
|
||||
)
|
||||
updated = True
|
||||
|
||||
if not updated:
|
||||
return False
|
||||
|
||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
||||
save_config(full_config)
|
||||
return True
|
||||
|
||||
|
||||
def qr_register(
|
||||
*,
|
||||
initial_domain: str = "feishu",
|
||||
@@ -600,7 +878,7 @@ class FeishuChannel(BaseChannel):
|
||||
self.config: FeishuConfig = config
|
||||
self._client: Any = None
|
||||
self._ws_client: Any = None
|
||||
self._ws_thread: threading.Thread | None = None
|
||||
self._ws_runner = get_feishu_ws_runner()
|
||||
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
|
||||
@@ -650,18 +928,11 @@ class FeishuChannel(BaseChannel):
|
||||
self.config.app_secret = result["app_secret"]
|
||||
self.config.domain = result.get("domain", "feishu")
|
||||
|
||||
# Write credentials back to config.json
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
|
||||
full_config = load_config()
|
||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
||||
if isinstance(feishu_cfg, dict):
|
||||
feishu_cfg["appId"] = result["app_id"]
|
||||
feishu_cfg["appSecret"] = result["app_secret"]
|
||||
feishu_cfg["domain"] = result.get("domain", "feishu")
|
||||
feishu_cfg["enabled"] = True
|
||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
||||
save_config(full_config)
|
||||
save_registration_result(
|
||||
result,
|
||||
instance_id=self.config.instance_id,
|
||||
name=self.config.name,
|
||||
)
|
||||
|
||||
_LOGIN_CONSOLE.print("\n[green]Feishu/Lark login complete.[/green]")
|
||||
_LOGIN_CONSOLE.print(f"App ID: {escape(result['app_id'])}")
|
||||
@@ -687,6 +958,18 @@ class FeishuChannel(BaseChannel):
|
||||
)
|
||||
return
|
||||
|
||||
if sync_saved_feishu_identity_boundary(
|
||||
instance_id=self.config.instance_id,
|
||||
app_id=self.config.app_id,
|
||||
domain=self.config.domain,
|
||||
):
|
||||
self.config.identity_key = _feishu_app_identity_key(self.config.app_id, self.config.domain)
|
||||
self.config.allow_from = []
|
||||
self.logger.info(
|
||||
"Feishu app identity changed for {}; cleared paired users for this assistant",
|
||||
self.name,
|
||||
)
|
||||
|
||||
lark, feishu_domain, lark_domain = await asyncio.to_thread(_load_lark_runtime)
|
||||
|
||||
redirect_lib_logging("Lark")
|
||||
@@ -745,38 +1028,7 @@ class FeishuChannel(BaseChannel):
|
||||
log_level=lark.LogLevel.INFO,
|
||||
)
|
||||
|
||||
# Start WebSocket client in a separate thread with reconnect loop.
|
||||
# A dedicated event loop is created for this thread so that lark_oapi's
|
||||
# module-level `loop = asyncio.get_event_loop()` picks up an idle loop
|
||||
# instead of the already-running main asyncio loop, which would cause
|
||||
# "This event loop is already running" errors.
|
||||
def run_ws():
|
||||
import time
|
||||
|
||||
import lark_oapi.ws.client as _lark_ws_client
|
||||
|
||||
previous_loop = getattr(_lark_ws_client, "loop", None)
|
||||
ws_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(ws_loop)
|
||||
# Patch the module-level loop used by lark's ws Client.start()
|
||||
_lark_ws_client.loop = ws_loop
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
self._ws_client.start()
|
||||
except Exception as e:
|
||||
self.logger.warning("WebSocket error: {}", e)
|
||||
if self._running:
|
||||
time.sleep(5)
|
||||
finally:
|
||||
if getattr(_lark_ws_client, "loop", None) is ws_loop:
|
||||
_lark_ws_client.loop = previous_loop
|
||||
with suppress(Exception):
|
||||
asyncio.set_event_loop(None)
|
||||
ws_loop.close()
|
||||
|
||||
self._ws_thread = threading.Thread(target=run_ws, daemon=True)
|
||||
self._ws_thread.start()
|
||||
await self._ws_runner.start_client(self.name, self._ws_client)
|
||||
|
||||
# Fetch bot's own open_id for accurate @mention matching
|
||||
self._bot_open_id = await asyncio.get_running_loop().run_in_executor(
|
||||
@@ -803,6 +1055,7 @@ class FeishuChannel(BaseChannel):
|
||||
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
|
||||
"""
|
||||
self._running = False
|
||||
await self._ws_runner.stop_client(self.name)
|
||||
self.logger.info("bot stopped")
|
||||
|
||||
def _fetch_bot_open_id(self) -> str | None:
|
||||
@@ -1589,13 +1842,95 @@ class FeishuChannel(BaseChannel):
|
||||
response.msg,
|
||||
response.get_log_id(),
|
||||
)
|
||||
if msg_type == "interactive":
|
||||
return self._reply_interactive_fallback_sync(
|
||||
parent_message_id,
|
||||
content,
|
||||
reply_in_thread=reply_in_thread,
|
||||
)
|
||||
return False
|
||||
self.logger.debug("reply sent to message {}", parent_message_id)
|
||||
return True
|
||||
except Exception:
|
||||
self.logger.exception("Error replying to message {}", parent_message_id)
|
||||
if msg_type == "interactive":
|
||||
return self._reply_interactive_fallback_sync(
|
||||
parent_message_id,
|
||||
content,
|
||||
reply_in_thread=reply_in_thread,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _interactive_content_to_text(content: str) -> str | None:
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
parts = [part.strip() for part in _extract_interactive_content(payload) if part.strip()]
|
||||
text = "\n".join(parts).strip()
|
||||
return text or None
|
||||
|
||||
@staticmethod
|
||||
def _fallback_text_chunks(text: str, limit: int = 3500) -> list[str]:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
if len(remaining) <= limit:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
split_at = remaining.rfind("\n", 0, limit)
|
||||
if split_at < limit // 2:
|
||||
split_at = limit
|
||||
chunks.append(remaining[:split_at].strip())
|
||||
remaining = remaining[split_at:].strip()
|
||||
return [chunk for chunk in chunks if chunk]
|
||||
|
||||
def _reply_interactive_fallback_sync(
|
||||
self,
|
||||
parent_message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_in_thread: bool = False,
|
||||
) -> bool:
|
||||
text = self._interactive_content_to_text(content)
|
||||
if not text:
|
||||
return False
|
||||
sent = False
|
||||
for chunk in self._fallback_text_chunks(text):
|
||||
body = json.dumps({"text": chunk}, ensure_ascii=False)
|
||||
sent = self._reply_message_sync(
|
||||
parent_message_id,
|
||||
"text",
|
||||
body,
|
||||
reply_in_thread=reply_in_thread,
|
||||
) or sent
|
||||
if sent:
|
||||
self.logger.warning("Sent Feishu interactive reply as text fallback")
|
||||
return sent
|
||||
|
||||
def _send_interactive_fallback_sync(
|
||||
self,
|
||||
receive_id_type: str,
|
||||
receive_id: str,
|
||||
content: str,
|
||||
) -> str | None:
|
||||
text = self._interactive_content_to_text(content)
|
||||
if not text:
|
||||
return None
|
||||
last_message_id: str | None = None
|
||||
for chunk in self._fallback_text_chunks(text):
|
||||
body = json.dumps({"text": chunk}, ensure_ascii=False)
|
||||
message_id = self._send_message_sync(receive_id_type, receive_id, "text", body)
|
||||
if message_id:
|
||||
last_message_id = message_id
|
||||
if last_message_id:
|
||||
self.logger.warning("Sent Feishu interactive message as text fallback")
|
||||
return last_message_id
|
||||
|
||||
def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool:
|
||||
"""Return whether a group reply should create a Feishu thread/topic."""
|
||||
return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message
|
||||
@@ -1639,6 +1974,12 @@ class FeishuChannel(BaseChannel):
|
||||
response.msg,
|
||||
response.get_log_id(),
|
||||
)
|
||||
if msg_type == "interactive":
|
||||
return self._send_interactive_fallback_sync(
|
||||
receive_id_type,
|
||||
receive_id,
|
||||
content,
|
||||
)
|
||||
return None
|
||||
msg_id = getattr(response.data, "message_id", None)
|
||||
self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id)
|
||||
@@ -2135,11 +2476,15 @@ class FeishuChannel(BaseChannel):
|
||||
Sync handler for incoming messages (called from WebSocket thread).
|
||||
Schedules async handling in the main event loop.
|
||||
"""
|
||||
if not self._running:
|
||||
return
|
||||
if self._loop and self._loop.is_running():
|
||||
asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
|
||||
|
||||
async def _on_message(self, data: P2ImMessageReceiveV1) -> None:
|
||||
"""Handle incoming message from Feishu."""
|
||||
if not self._running:
|
||||
return
|
||||
try:
|
||||
event = data.event
|
||||
message = event.message
|
||||
@@ -2290,9 +2635,9 @@ class FeishuChannel(BaseChannel):
|
||||
# Private chat: no override — same behavior as Telegram/Slack.
|
||||
if chat_type == "group":
|
||||
if self.config.topic_isolation:
|
||||
session_key = f"feishu:{chat_id}:{root_id or message_id}"
|
||||
session_key = f"{self.name}:{chat_id}:{root_id or message_id}"
|
||||
else:
|
||||
session_key = f"feishu:{chat_id}"
|
||||
session_key = f"{self.name}:{chat_id}"
|
||||
else:
|
||||
session_key = None
|
||||
|
||||
|
||||
+270
-64
@@ -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:
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypeAlias
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
@@ -92,6 +94,19 @@ MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = {
|
||||
"img": {"src", "alt", "title", "width", "height"},
|
||||
}
|
||||
MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"}
|
||||
_MXC_IMAGE_PLACEHOLDER_PREFIX = "https://nanobot.invalid/matrix-mxc/"
|
||||
_MXC_MARKDOWN_IMAGE_RE = re.compile(
|
||||
r"(?P<prefix>!\[[^\]]*\]\()"
|
||||
r"(?P<value>mxc://[^\s)]+)"
|
||||
r"(?P<suffix>(?:\s+[^)]*)?\))"
|
||||
)
|
||||
_MXC_IMAGE_SRC_RE = re.compile(
|
||||
r"(?P<prefix>\bsrc=)(?P<quote>[\"'])(?P<value>mxc://[^\"']+)(?P=quote)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MXC_PLACEHOLDER_SRC_RE = re.compile(
|
||||
rf'src="{re.escape(_MXC_IMAGE_PLACEHOLDER_PREFIX)}([^"]+)"'
|
||||
)
|
||||
|
||||
|
||||
def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None:
|
||||
@@ -99,7 +114,10 @@ def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None
|
||||
if tag == "a" and attr == "href":
|
||||
return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None
|
||||
if tag == "img" and attr == "src":
|
||||
return value if value.lower().startswith("mxc://") else None
|
||||
lowered = value.lower()
|
||||
if lowered.startswith("mxc://") or lowered.startswith(_MXC_IMAGE_PLACEHOLDER_PREFIX):
|
||||
return value
|
||||
return None
|
||||
if tag == "code" and attr == "class":
|
||||
classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")]
|
||||
return " ".join(classes) if classes else None
|
||||
@@ -115,6 +133,39 @@ MATRIX_HTML_CLEANER = nh3.Cleaner(
|
||||
link_rel="noopener noreferrer",
|
||||
)
|
||||
|
||||
|
||||
def _mask_mxc_markdown_image_sources(text: str) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
value = quote(match.group("value"), safe="")
|
||||
return (
|
||||
f"{match.group('prefix')}"
|
||||
f"{_MXC_IMAGE_PLACEHOLDER_PREFIX}{value}"
|
||||
f"{match.group('suffix')}"
|
||||
)
|
||||
|
||||
return _MXC_MARKDOWN_IMAGE_RE.sub(repl, text)
|
||||
|
||||
|
||||
def _mask_mxc_image_sources(rendered_html: str) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
value = quote(match.group("value"), safe="")
|
||||
return (
|
||||
f'{match.group("prefix")}{match.group("quote")}'
|
||||
f"{_MXC_IMAGE_PLACEHOLDER_PREFIX}{value}"
|
||||
f'{match.group("quote")}'
|
||||
)
|
||||
|
||||
return _MXC_IMAGE_SRC_RE.sub(repl, rendered_html)
|
||||
|
||||
|
||||
def _unmask_mxc_image_sources(cleaned_html: str) -> str:
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
value = html.escape(unquote(match.group(1)), quote=True)
|
||||
return f'src="{value}"'
|
||||
|
||||
return _MXC_PLACEHOLDER_SRC_RE.sub(repl, cleaned_html)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StreamBuf:
|
||||
"""
|
||||
@@ -135,7 +186,9 @@ class _StreamBuf:
|
||||
def _render_markdown_html(text: str) -> str | None:
|
||||
"""Render markdown to sanitized HTML; returns None for plain text."""
|
||||
try:
|
||||
formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip()
|
||||
masked_text = _mask_mxc_markdown_image_sources(text)
|
||||
rendered = _mask_mxc_image_sources(MATRIX_MARKDOWN(masked_text))
|
||||
formatted = _unmask_mxc_image_sources(MATRIX_HTML_CLEANER.clean(rendered).strip())
|
||||
except Exception:
|
||||
return None
|
||||
if not formatted:
|
||||
|
||||
@@ -10,7 +10,11 @@ from loguru import logger
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.channels.base import BaseChannel
|
||||
|
||||
_INTERNAL = frozenset({"base", "manager", "registry"})
|
||||
_INTERNAL = frozenset({
|
||||
"base",
|
||||
"manager",
|
||||
"registry",
|
||||
})
|
||||
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
|
||||
|
||||
|
||||
@@ -21,7 +25,7 @@ def discover_channel_names() -> list[str]:
|
||||
return [
|
||||
name
|
||||
for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
|
||||
if name not in _INTERNAL and not ispkg
|
||||
if name not in _INTERNAL and not name.startswith("_") and not ispkg
|
||||
]
|
||||
|
||||
|
||||
|
||||
+252
-46
@@ -5,6 +5,7 @@ import os
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Callable, Iterable
|
||||
from contextlib import nullcontext, suppress
|
||||
from pathlib import Path
|
||||
@@ -75,6 +76,7 @@ from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
||||
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
||||
from nanobot.config.schema import Config # noqa: E402
|
||||
from nanobot.security.network import is_loopback_host # noqa: E402
|
||||
from nanobot.utils.evaluator import evaluate_response # noqa: E402
|
||||
from nanobot.utils.helpers import sync_workspace_templates # noqa: E402
|
||||
from nanobot.utils.restart import ( # noqa: E402
|
||||
@@ -82,6 +84,11 @@ from nanobot.utils.restart import ( # noqa: E402
|
||||
format_restart_completed_message,
|
||||
should_show_cli_restart_notice,
|
||||
)
|
||||
from nanobot.webui.build import ( # noqa: E402
|
||||
BuildMode,
|
||||
WebUIBuildError,
|
||||
ensure_webui_bundle,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
|
||||
|
||||
|
||||
@@ -724,25 +731,12 @@ def onboard(
|
||||
)
|
||||
|
||||
|
||||
def _merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
||||
"""Recursively fill in missing values from defaults without overwriting user config."""
|
||||
if not isinstance(existing, dict) or not isinstance(defaults, dict):
|
||||
return existing
|
||||
|
||||
merged = dict(existing)
|
||||
for key, value in defaults.items():
|
||||
if key not in merged:
|
||||
merged[key] = value
|
||||
else:
|
||||
merged[key] = _merge_missing_defaults(merged[key], value)
|
||||
return merged
|
||||
|
||||
|
||||
def _onboard_plugins(config_path: Path) -> None:
|
||||
"""Inject default config for all discovered channels (built-in + plugins)."""
|
||||
import json
|
||||
|
||||
from nanobot.channels.registry import discover_all
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
|
||||
all_channels = discover_all()
|
||||
if not all_channels:
|
||||
@@ -756,7 +750,7 @@ def _onboard_plugins(config_path: Path) -> None:
|
||||
if name not in channels:
|
||||
channels[name] = cls.default_config()
|
||||
else:
|
||||
channels[name] = _merge_missing_defaults(channels[name], cls.default_config())
|
||||
channels[name] = merge_missing_defaults(channels[name], cls.default_config())
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
@@ -883,11 +877,7 @@ def _confirm_webui_action(message: str, *, yes: bool) -> None:
|
||||
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
|
||||
if yes:
|
||||
return
|
||||
try:
|
||||
interactive = sys.stdin.isatty()
|
||||
except Exception:
|
||||
interactive = False
|
||||
if not interactive:
|
||||
if not _cli_can_prompt():
|
||||
console.print(
|
||||
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
|
||||
"`nanobot onboard --wizard`.[/red]"
|
||||
@@ -898,6 +888,19 @@ def _confirm_webui_action(message: str, *, yes: bool) -> None:
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _cli_can_prompt() -> bool:
|
||||
try:
|
||||
return sys.stdin.isatty()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
|
||||
if yes:
|
||||
return "auto"
|
||||
return "prompt" if _cli_can_prompt() else "warn"
|
||||
|
||||
|
||||
def _resolve_webui_config_path(config: str | None) -> Path:
|
||||
"""Resolve the config path used by ``nanobot webui`` and bind loader state."""
|
||||
from nanobot.config.loader import get_config_path, set_config_path
|
||||
@@ -942,6 +945,43 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
|
||||
return model.model_dump(by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
def _webui_channel_enabled(config: Config) -> bool:
|
||||
from nanobot.channels.websocket import WebSocketConfig
|
||||
|
||||
current = getattr(config.channels, "websocket", None) or {}
|
||||
return bool(WebSocketConfig.model_validate(current).enabled)
|
||||
|
||||
|
||||
def _prepare_webui_bundle_for_gateway(
|
||||
config: Config,
|
||||
*,
|
||||
mode: BuildMode,
|
||||
webui_static_dist: bool = True,
|
||||
) -> None:
|
||||
"""Refresh or warn about stale bundled WebUI assets before gateway startup."""
|
||||
if not webui_static_dist or not _webui_channel_enabled(config):
|
||||
return
|
||||
|
||||
def _print(message: str) -> None:
|
||||
console.print(f"[yellow]{escape(message)}[/yellow]")
|
||||
|
||||
def _confirm(message: str) -> bool:
|
||||
return typer.confirm(message, default=True)
|
||||
|
||||
try:
|
||||
ensure_webui_bundle(
|
||||
mode=mode,
|
||||
confirm=_confirm if mode == "prompt" else None,
|
||||
output=_print,
|
||||
)
|
||||
except WebUIBuildError as exc:
|
||||
if mode == "warn":
|
||||
console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
|
||||
return
|
||||
console.print(f"[red]Error: {escape(str(exc))}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
def _host_for_local_browser(host: str) -> str:
|
||||
"""Map bind hosts to a browser-openable local host."""
|
||||
if host in {"0.0.0.0", ""}:
|
||||
@@ -1041,7 +1081,6 @@ def _warn_webui_bind_scope(config: Config) -> None:
|
||||
|
||||
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
|
||||
"""Best-effort wait for the WebUI listener before opening a browser."""
|
||||
import socket
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -1050,11 +1089,75 @@ def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=0.2):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
|
||||
"""Return whether a local TCP endpoint accepts connections."""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout_s):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
|
||||
"""Return whether the nanobot gateway health endpoint responds OK."""
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
browser_host = _host_for_local_browser(host)
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
f"http://{browser_host}:{port}/health",
|
||||
timeout=timeout_s,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return False
|
||||
body = response.read(1024)
|
||||
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
return payload.get("status") == "ok"
|
||||
|
||||
|
||||
def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
|
||||
"""Return whether the WebUI URL's TCP endpoint is already listening."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or "127.0.0.1"
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
|
||||
|
||||
|
||||
def _print_foreground_port_conflict(
|
||||
*,
|
||||
webui_url: str,
|
||||
gateway_host: str,
|
||||
gateway_port: int,
|
||||
) -> None:
|
||||
console.print(
|
||||
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
|
||||
)
|
||||
console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
|
||||
console.print(
|
||||
f" Gateway health: [cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
|
||||
)
|
||||
console.print()
|
||||
console.print("If this is an existing nanobot instance, use it or stop it first:")
|
||||
console.print(" [cyan]nanobot gateway status[/cyan]")
|
||||
console.print(" [cyan]nanobot gateway stop[/cyan]")
|
||||
console.print("Or choose different ports with [cyan]--port[/cyan] and [cyan]--gateway-port[/cyan].")
|
||||
|
||||
|
||||
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
|
||||
@@ -1063,11 +1166,41 @@ def _open_webui_browser(url: str, *, wait: bool = True) -> None:
|
||||
|
||||
if wait:
|
||||
_wait_for_webui(url)
|
||||
display_url = _webui_display_url(url)
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{url}[/cyan]")
|
||||
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
|
||||
except Exception as exc:
|
||||
console.print(f"[yellow]Could not open browser ({exc}); visit {url}[/yellow]")
|
||||
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
|
||||
|
||||
|
||||
def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
||||
"""Explain how the browser and gateway lifecycles differ."""
|
||||
console.print()
|
||||
if attached:
|
||||
console.print("[green]nanobot is attached to the existing gateway.[/green]")
|
||||
else:
|
||||
console.print("[green]nanobot is running in this terminal.[/green]")
|
||||
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
||||
|
||||
|
||||
def _attach_to_background_gateway(runtime: Any) -> None:
|
||||
"""Keep a foreground WebUI command attached to a managed gateway."""
|
||||
_print_webui_foreground_lifecycle(attached=True)
|
||||
try:
|
||||
while runtime.status().running:
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Stopping nanobot...[/yellow]")
|
||||
result = runtime.stop()
|
||||
if result.ok or result.message == "gateway_not_running":
|
||||
console.print("[green]Gateway stopped.[/green]")
|
||||
return
|
||||
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print("[yellow]Gateway stopped.[/yellow]")
|
||||
|
||||
|
||||
def _gateway_instance_command(
|
||||
@@ -1191,9 +1324,9 @@ def serve(
|
||||
port = port if port is not None else api_cfg.port
|
||||
timeout = timeout if timeout is not None else api_cfg.timeout
|
||||
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
|
||||
if host in {"0.0.0.0", "::"} and not api_key:
|
||||
if not is_loopback_host(host) and not api_key:
|
||||
console.print(
|
||||
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
|
||||
f"[red]Error: host {host} is available beyond this device but api_key is not set. "
|
||||
"Set api.api_key in config to prevent unauthenticated access.[/red]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
@@ -1217,9 +1350,9 @@ def serve(
|
||||
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
|
||||
console.print(" [cyan]Session[/cyan] : api:default")
|
||||
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
if not is_loopback_host(host):
|
||||
console.print(
|
||||
"[yellow]API is bound to all interfaces "
|
||||
"[yellow]API is available beyond this device "
|
||||
"(authentication required).[/yellow]"
|
||||
)
|
||||
console.print()
|
||||
@@ -1256,7 +1389,11 @@ def webui(
|
||||
),
|
||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
background: bool = typer.Option(False, "--background", help="Start gateway in the background"),
|
||||
background: bool = typer.Option(
|
||||
False,
|
||||
"--background",
|
||||
help="Keep the gateway running after this command exits",
|
||||
),
|
||||
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
|
||||
yes: bool = typer.Option(
|
||||
False,
|
||||
@@ -1323,21 +1460,25 @@ def webui(
|
||||
f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
|
||||
)
|
||||
|
||||
if background:
|
||||
config_arg = str(config_path)
|
||||
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||
runtime = GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=config_path.parent,
|
||||
workspace=workspace_arg,
|
||||
config_path=config_arg,
|
||||
)
|
||||
)
|
||||
start_options = GatewayStartOptions(
|
||||
port=effective_gateway_port,
|
||||
webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
|
||||
|
||||
config_arg = str(config_path)
|
||||
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||
runtime = GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=config_path.parent,
|
||||
workspace=workspace_arg,
|
||||
config_path=config_arg,
|
||||
)
|
||||
)
|
||||
start_options = GatewayStartOptions(
|
||||
port=effective_gateway_port,
|
||||
workspace=workspace_arg,
|
||||
config_path=config_arg,
|
||||
)
|
||||
|
||||
if background:
|
||||
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
|
||||
result = runtime.start_background(start_options)
|
||||
restarted = False
|
||||
restart_attempted = False
|
||||
@@ -1365,14 +1506,53 @@ def webui(
|
||||
"View logs: "
|
||||
f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
)
|
||||
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||
console.print(
|
||||
"Stop nanobot: "
|
||||
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
)
|
||||
if not no_open:
|
||||
_open_webui_browser(webui_url)
|
||||
return
|
||||
|
||||
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
|
||||
webui_ready = _webui_endpoint_reachable(webui_url)
|
||||
if gateway_ready and webui_ready:
|
||||
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
|
||||
console.print(
|
||||
"Restart the gateway if you need it to pick up local source changes: "
|
||||
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
)
|
||||
if not no_open:
|
||||
_open_webui_browser(webui_url, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(runtime)
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]This gateway is controlled by another foreground command. "
|
||||
"Stop it from that terminal.[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
|
||||
_host_for_local_browser(runtime_config.gateway.host),
|
||||
effective_gateway_port,
|
||||
)
|
||||
webui_port_taken = webui_ready
|
||||
if gateway_port_taken or webui_port_taken:
|
||||
_print_foreground_port_conflict(
|
||||
webui_url=webui_url,
|
||||
gateway_host=runtime_config.gateway.host,
|
||||
gateway_port=effective_gateway_port,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
_print_webui_foreground_lifecycle(attached=False)
|
||||
_run_gateway(
|
||||
runtime_config,
|
||||
port=effective_gateway_port,
|
||||
open_browser_url=None if no_open else webui_url,
|
||||
webui_bundle_mode=webui_bundle_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -1387,6 +1567,7 @@ def _run_gateway(
|
||||
port: int | None = None,
|
||||
open_browser_url: str | None = None,
|
||||
webui_static_dist: bool = True,
|
||||
webui_bundle_mode: BuildMode = "warn",
|
||||
webui_runtime_surface: str = "browser",
|
||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||
health_server_enabled: bool = True,
|
||||
@@ -1409,8 +1590,29 @@ def _run_gateway(
|
||||
from nanobot.webui.token_usage import TokenUsageHook
|
||||
|
||||
port = port if port is not None else config.gateway.port
|
||||
webui_url = _webui_browser_url(config)
|
||||
gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
|
||||
if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
|
||||
_print_foreground_port_conflict(
|
||||
webui_url=webui_url,
|
||||
gateway_host=config.gateway.host,
|
||||
gateway_port=port,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
|
||||
_print_foreground_port_conflict(
|
||||
webui_url=webui_url,
|
||||
gateway_host=config.gateway.host,
|
||||
gateway_port=port,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
|
||||
_prepare_webui_bundle_for_gateway(
|
||||
config,
|
||||
mode=webui_bundle_mode,
|
||||
webui_static_dist=webui_static_dist,
|
||||
)
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
bus = MessageBus()
|
||||
runtime_events = RuntimeEventBus()
|
||||
@@ -1895,6 +2097,10 @@ app.add_typer(
|
||||
log_handler_id=_log_handler_id,
|
||||
load_runtime_config=_load_runtime_config,
|
||||
run_gateway=_run_gateway,
|
||||
prepare_webui_bundle=lambda config, mode: _prepare_webui_bundle_for_gateway(
|
||||
config,
|
||||
mode=mode,
|
||||
),
|
||||
),
|
||||
name="gateway",
|
||||
)
|
||||
@@ -2274,7 +2480,7 @@ def plugins_list(
|
||||
|
||||
@plugins_app.command("enable")
|
||||
def plugins_enable(
|
||||
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"),
|
||||
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, bedrock)"),
|
||||
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"),
|
||||
):
|
||||
|
||||
+19
-2
@@ -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,
|
||||
)
|
||||
|
||||
@@ -89,6 +89,20 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
||||
"""Recursively add missing defaults without replacing configured values."""
|
||||
if not isinstance(existing, dict) or not isinstance(defaults, dict):
|
||||
return existing
|
||||
|
||||
merged = dict(existing)
|
||||
for key, value in defaults.items():
|
||||
if key not in merged:
|
||||
merged[key] = value
|
||||
else:
|
||||
merged[key] = merge_missing_defaults(merged[key], value)
|
||||
return merged
|
||||
|
||||
|
||||
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
|
||||
|
||||
+24
-406
@@ -1,60 +1,27 @@
|
||||
"""Background process control for ``nanobot gateway``.
|
||||
|
||||
This module intentionally stays small: the CLI owns command wording, while this
|
||||
runtime owns process state, log files, and platform-specific detach/stop details.
|
||||
"""
|
||||
"""Gateway-specific configuration for the shared background process runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import hashlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.process_runtime import (
|
||||
ManagedProcessRuntime,
|
||||
ProcessResult,
|
||||
ProcessRuntimePaths,
|
||||
ProcessStartOptions,
|
||||
ProcessStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayStartOptions:
|
||||
"""Options needed to start a background gateway instance."""
|
||||
|
||||
port: int
|
||||
verbose: bool = False
|
||||
workspace: str | None = None
|
||||
config_path: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayStatus:
|
||||
"""Current background gateway status."""
|
||||
|
||||
running: bool
|
||||
pid: int | None
|
||||
state_path: Path
|
||||
log_path: Path
|
||||
started_at: str | None = None
|
||||
port: int | None = None
|
||||
command: tuple[str, ...] = ()
|
||||
reason: str = "not_started"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeResult:
|
||||
"""Result from a gateway runtime control operation."""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
status: GatewayStatus
|
||||
GatewayStartOptions = ProcessStartOptions
|
||||
GatewayStatus = ProcessStatus
|
||||
RuntimeResult = ProcessResult
|
||||
|
||||
|
||||
def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]:
|
||||
@@ -78,14 +45,9 @@ def build_gateway_command(python_executable: str, options: GatewayStartOptions)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayRuntimePaths:
|
||||
class GatewayRuntimePaths(ProcessRuntimePaths):
|
||||
"""Filesystem layout for one gateway runtime instance."""
|
||||
|
||||
run_dir: Path
|
||||
logs_dir: Path
|
||||
state_path: Path
|
||||
log_path: Path
|
||||
|
||||
@classmethod
|
||||
def for_instance(
|
||||
cls,
|
||||
@@ -107,9 +69,11 @@ class GatewayRuntimePaths:
|
||||
)
|
||||
|
||||
|
||||
class GatewayRuntime:
|
||||
class GatewayRuntime(ManagedProcessRuntime):
|
||||
"""Manage a background ``nanobot gateway`` process."""
|
||||
|
||||
service_name = "gateway"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -120,367 +84,21 @@ class GatewayRuntime:
|
||||
subprocess_run: Callable[..., Any] = subprocess.run,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
self.paths = paths or GatewayRuntimePaths.for_instance()
|
||||
self.platform_name = platform_name or _platform_name()
|
||||
self.python_executable = python_executable or sys.executable
|
||||
self._popen = popen
|
||||
self._subprocess_run = subprocess_run
|
||||
self._sleep = sleep
|
||||
|
||||
@classmethod
|
||||
def refresh_state_pid(cls, *, paths: GatewayRuntimePaths) -> None:
|
||||
"""Update the PID in an existing state file to ``os.getpid()``.
|
||||
|
||||
Called early in gateway server startup so the state file self-heals
|
||||
after any restart, regardless of platform or restart mechanism.
|
||||
"""
|
||||
if not paths.state_path.exists():
|
||||
return
|
||||
try:
|
||||
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return
|
||||
state["pid"] = os.getpid()
|
||||
rt = cls(paths=paths)
|
||||
state["identity"] = rt._process_identity(os.getpid())
|
||||
state["started_at"] = _utc_now()
|
||||
rt._write_state(state)
|
||||
|
||||
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
||||
"""Start gateway as a detached background process."""
|
||||
current = self.status()
|
||||
if current.running:
|
||||
return RuntimeResult(False, "gateway_already_running", current)
|
||||
|
||||
command = self._build_child_command(options)
|
||||
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with self.paths.log_path.open("a", encoding="utf-8") as log_handle:
|
||||
process = self._popen(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
**self._popen_platform_kwargs(),
|
||||
)
|
||||
|
||||
pid = int(process.pid)
|
||||
self._sleep(0.2)
|
||||
if not self._is_pid_running(pid):
|
||||
return RuntimeResult(False, "gateway_exited_during_startup", self.status())
|
||||
|
||||
identity = self._process_identity(pid)
|
||||
self._write_state(
|
||||
{
|
||||
"pid": pid,
|
||||
"identity": identity,
|
||||
"started_at": _utc_now(),
|
||||
"platform": self.platform_name,
|
||||
"port": options.port,
|
||||
"workspace": options.workspace,
|
||||
"config_path": options.config_path,
|
||||
"command": command,
|
||||
"log_path": str(self.paths.log_path),
|
||||
}
|
||||
)
|
||||
return RuntimeResult(True, "gateway_started_background", self.status())
|
||||
|
||||
def stop(self, *, timeout_s: int = 20) -> RuntimeResult:
|
||||
"""Stop the recorded background gateway process."""
|
||||
status = self.status()
|
||||
if not status.pid:
|
||||
return RuntimeResult(False, "gateway_not_running", status)
|
||||
|
||||
state = self._read_state()
|
||||
if not self._record_matches_process(state, status.pid):
|
||||
self._clear_state()
|
||||
return RuntimeResult(False, "gateway_state_stale", self.status(reason="stale_state"))
|
||||
|
||||
if not self._terminate(status.pid, timeout_s=timeout_s):
|
||||
return RuntimeResult(False, "gateway_stop_timeout", self.status(reason="stop_timeout"))
|
||||
self._clear_state()
|
||||
return RuntimeResult(True, "gateway_stopped", self.status(reason="stopped"))
|
||||
|
||||
def restart(self, options: GatewayStartOptions, *, timeout_s: int = 20) -> RuntimeResult:
|
||||
"""Restart the background gateway."""
|
||||
stop_result = self.stop(timeout_s=timeout_s)
|
||||
if not stop_result.ok and stop_result.message not in {"gateway_not_running", "gateway_state_stale"}:
|
||||
return stop_result
|
||||
return self.start_background(options)
|
||||
|
||||
def status(self, *, reason: str | None = None) -> GatewayStatus:
|
||||
"""Return live status, clearing stale state when needed."""
|
||||
state = self._read_state()
|
||||
pid = _as_int(state.get("pid")) if state else None
|
||||
if pid is None:
|
||||
return GatewayStatus(
|
||||
running=False,
|
||||
pid=None,
|
||||
state_path=self.paths.state_path,
|
||||
log_path=self.paths.log_path,
|
||||
reason=reason or "not_started",
|
||||
)
|
||||
|
||||
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
|
||||
self._clear_state()
|
||||
return GatewayStatus(
|
||||
running=False,
|
||||
pid=None,
|
||||
state_path=self.paths.state_path,
|
||||
log_path=self.paths.log_path,
|
||||
reason=reason or "stale_state",
|
||||
)
|
||||
|
||||
command = state.get("command")
|
||||
return GatewayStatus(
|
||||
running=True,
|
||||
pid=pid,
|
||||
state_path=self.paths.state_path,
|
||||
log_path=self.paths.log_path,
|
||||
started_at=_as_str(state.get("started_at")),
|
||||
port=_as_int(state.get("port")),
|
||||
command=tuple(command) if isinstance(command, list) else (),
|
||||
reason=reason or "running",
|
||||
super().__init__(
|
||||
paths=paths or GatewayRuntimePaths.for_instance(),
|
||||
platform_name=platform_name,
|
||||
python_executable=python_executable,
|
||||
popen=popen,
|
||||
subprocess_run=subprocess_run,
|
||||
sleep=sleep,
|
||||
)
|
||||
|
||||
def read_log_tail(self, *, tail: int = 200) -> list[str]:
|
||||
"""Return the last ``tail`` log lines."""
|
||||
if tail <= 0 or not self.paths.log_path.exists():
|
||||
return []
|
||||
try:
|
||||
lines = self.paths.log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
return lines[-tail:]
|
||||
|
||||
def follow_logs(self, *, tail: int = 200) -> int:
|
||||
"""Print existing log tail and follow new log lines."""
|
||||
for line in self.read_log_tail(tail=tail):
|
||||
print(line)
|
||||
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.paths.log_path.touch(exist_ok=True)
|
||||
try:
|
||||
with self.paths.log_path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
handle.seek(0, os.SEEK_END)
|
||||
while True:
|
||||
line = handle.readline()
|
||||
if line:
|
||||
print(line.rstrip("\n"))
|
||||
else:
|
||||
self._sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
|
||||
def _build_child_command(self, options: GatewayStartOptions) -> list[str]:
|
||||
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
|
||||
return build_gateway_command(self.python_executable, options)
|
||||
|
||||
def _popen_platform_kwargs(self) -> dict[str, Any]:
|
||||
if self.platform_name == "Windows":
|
||||
flags = 0
|
||||
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
return {"creationflags": flags}
|
||||
return {"start_new_session": True}
|
||||
|
||||
def _terminate(self, pid: int, *, timeout_s: int) -> bool:
|
||||
if self.platform_name == "Windows":
|
||||
return self._terminate_windows(pid, timeout_s=timeout_s)
|
||||
return self._terminate_posix(pid, timeout_s=timeout_s)
|
||||
|
||||
def _terminate_posix(self, pid: int, *, timeout_s: int) -> bool:
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
except OSError:
|
||||
pgid = None
|
||||
try:
|
||||
if pgid is not None:
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
else:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
if self._wait_for_exit(pid, timeout_s):
|
||||
return True
|
||||
with suppress(ProcessLookupError):
|
||||
if pgid is not None:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
else:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
return self._wait_for_exit(pid, 2)
|
||||
|
||||
def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool:
|
||||
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
|
||||
if ctrl_break is not None:
|
||||
# Detached Windows children can reject CTRL_BREAK_EVENT with WinError 87;
|
||||
# keep the existing taskkill fallback for that process shape.
|
||||
ctrl_break_sent = False
|
||||
try:
|
||||
os.kill(pid, ctrl_break)
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
ctrl_break_sent = True
|
||||
if ctrl_break_sent and self._wait_for_exit(pid, timeout_s):
|
||||
return True
|
||||
self._subprocess_run(
|
||||
["taskkill", "/PID", str(pid), "/T"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if self._wait_for_exit(pid, 2):
|
||||
return True
|
||||
self._subprocess_run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return self._wait_for_exit(pid, 2)
|
||||
|
||||
def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool:
|
||||
deadline = time.monotonic() + max(float(timeout_s), 0.0)
|
||||
while time.monotonic() < deadline:
|
||||
if not self._is_pid_running(pid):
|
||||
return True
|
||||
self._sleep(0.1)
|
||||
return not self._is_pid_running(pid)
|
||||
|
||||
def _is_pid_running(self, pid: int) -> bool:
|
||||
if pid <= 0:
|
||||
return False
|
||||
if self.platform_name == "Windows":
|
||||
return _windows_process_identity(pid) is not None
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _process_identity(self, pid: int) -> str | int | None:
|
||||
if self.platform_name == "Windows":
|
||||
return _windows_process_identity(pid)
|
||||
try:
|
||||
return os.getpgid(pid)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool:
|
||||
if not state:
|
||||
return False
|
||||
recorded = state.get("identity")
|
||||
if recorded is None:
|
||||
return True
|
||||
return recorded == self._process_identity(pid)
|
||||
|
||||
def _read_state(self) -> dict[str, Any] | None:
|
||||
try:
|
||||
with self.paths.state_path.open(encoding="utf-8") as handle:
|
||||
payload = json.load(handle)
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
def _write_state(self, payload: dict[str, Any]) -> None:
|
||||
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f"{self.paths.state_path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=self.paths.run_dir,
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
tmp_path.replace(self.paths.state_path)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
def _clear_state(self) -> None:
|
||||
self.paths.state_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | None:
|
||||
raw = "|".join(value for value in (workspace, config_path) if value)
|
||||
if not raw:
|
||||
return None
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _platform_name() -> str:
|
||||
if sys.platform.startswith("win"):
|
||||
return "Windows"
|
||||
if sys.platform == "darwin":
|
||||
return "Darwin"
|
||||
return "Linux"
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _as_int(value: object) -> int | None:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _as_str(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _windows_process_identity(pid: int) -> str | None:
|
||||
if os.name != "nt":
|
||||
return None
|
||||
|
||||
class FileTime(ctypes.Structure):
|
||||
_fields_ = [("low", ctypes.c_uint32), ("high", ctypes.c_uint32)]
|
||||
|
||||
@property
|
||||
def value(self) -> int:
|
||||
return (int(self.high) << 32) | int(self.low)
|
||||
|
||||
process_query_limited_information = 0x1000
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
||||
if not handle:
|
||||
return None
|
||||
try:
|
||||
creation_time = FileTime()
|
||||
exit_time = FileTime()
|
||||
kernel_time = FileTime()
|
||||
user_time = FileTime()
|
||||
ok = kernel32.GetProcessTimes(
|
||||
handle,
|
||||
ctypes.byref(creation_time),
|
||||
ctypes.byref(exit_time),
|
||||
ctypes.byref(kernel_time),
|
||||
ctypes.byref(user_time),
|
||||
)
|
||||
if not ok:
|
||||
return None
|
||||
exit_code = ctypes.c_uint32()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return None
|
||||
if exit_code.value != 259:
|
||||
return None
|
||||
return str(creation_time.value)
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
+237
-28
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}`."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path:
|
||||
**Question 2 — Optional dependencies:**
|
||||
|
||||
```
|
||||
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, documents, feishu, matrix, mochat, msteams, napcat, qq, slack, telegram, wecom, weixin, langsmith, pdf"
|
||||
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, feishu, langfuse, matrix, mochat, msteams, napcat, olostep, qq, slack, telegram, wecom, weixin"
|
||||
```
|
||||
|
||||
Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names.
|
||||
|
||||
+176
-26
@@ -1,7 +1,9 @@
|
||||
"""Document text extraction utilities for nanobot."""
|
||||
|
||||
import mimetypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -37,6 +39,60 @@ SUPPORTED_EXTENSIONS: set[str] = {
|
||||
}
|
||||
|
||||
_MAX_TEXT_LENGTH = 200_000
|
||||
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
|
||||
_MAX_OFFICE_UNCOMPRESSED_SIZE = 256 * 1024 * 1024 # 256 MB
|
||||
_MAX_OFFICE_MEMBER_SIZE = 128 * 1024 * 1024 # 128 MB
|
||||
_MAX_PDF_CONTENT_STREAM_SIZE = 32 * 1024 * 1024 # 32 MB per page
|
||||
_MAX_PDF_ATTACHMENT_PAGES = 100
|
||||
|
||||
|
||||
class _TextCollector:
|
||||
"""Build bounded parser output without retaining the full document text."""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
self.limit = limit
|
||||
self.parts: list[str] = []
|
||||
self.length = 0
|
||||
self.truncated = False
|
||||
|
||||
def add(self, text: str, *, separator: str = "") -> bool:
|
||||
if not text:
|
||||
return True
|
||||
prefix = separator if self.parts else ""
|
||||
chunk = prefix + text
|
||||
remaining = self.limit - self.length
|
||||
if len(chunk) > remaining:
|
||||
if remaining > 0:
|
||||
self.parts.append(chunk[:remaining])
|
||||
self.length += remaining
|
||||
self.truncated = True
|
||||
return False
|
||||
self.parts.append(chunk)
|
||||
self.length += len(chunk)
|
||||
return True
|
||||
|
||||
def render(self) -> str:
|
||||
text = "".join(self.parts)
|
||||
if self.truncated:
|
||||
text += f"... (truncated at {self.limit} chars)"
|
||||
return text
|
||||
|
||||
|
||||
class PdfSafetyError(Exception):
|
||||
"""Raised when a PDF exceeds a parser safety boundary."""
|
||||
|
||||
|
||||
class PdfPageRangeError(Exception):
|
||||
"""Raised when a requested PDF page range is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PdfExtraction:
|
||||
text: str
|
||||
total_pages: int
|
||||
start_page: int
|
||||
end_page: int
|
||||
|
||||
|
||||
def extract_text(path: Path) -> str | None:
|
||||
@@ -54,12 +110,16 @@ def extract_text(path: Path) -> str | None:
|
||||
|
||||
if not path.exists():
|
||||
return f"[error: file not found: {path}]"
|
||||
try:
|
||||
if path.stat().st_size > _MAX_EXTRACT_FILE_SIZE:
|
||||
return f"[error: file exceeds {_MAX_EXTRACT_FILE_SIZE // (1024 * 1024)} MB limit]"
|
||||
except OSError as e:
|
||||
return f"[error: failed to inspect file: {e!s}]"
|
||||
|
||||
ext = path.suffix.lower()
|
||||
|
||||
# Document formats -- each branch lazily imports its parser so that
|
||||
# startup does not pay the ~25 MB cost of loading openpyxl /
|
||||
# python-docx / python-pptx / pypdf up front (see issue #3422).
|
||||
# Parsers stay lazy even though they are bundled so idle processes do not
|
||||
# retain their import cost (see issue #3422).
|
||||
if ext == ".pdf":
|
||||
return _extract_pdf(path)
|
||||
elif ext == ".docx":
|
||||
@@ -81,21 +141,71 @@ def extract_text(path: Path) -> str | None:
|
||||
def _extract_pdf(path: Path) -> str:
|
||||
"""Extract text from PDF using pypdf."""
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
return "[error: pypdf not installed]"
|
||||
try:
|
||||
reader = PdfReader(path)
|
||||
pages: list[str] = []
|
||||
for i, page in enumerate(reader.pages, 1):
|
||||
text = page.extract_text() or ""
|
||||
pages.append(f"--- Page {i} ---\n{text}")
|
||||
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
|
||||
result = extract_pdf_pages(
|
||||
path,
|
||||
max_pages=_MAX_PDF_ATTACHMENT_PAGES,
|
||||
max_chars=_MAX_TEXT_LENGTH,
|
||||
)
|
||||
text = result.text
|
||||
if result.end_page < result.total_pages - 1:
|
||||
text += f"\n\n(Showing pages 1-{result.end_page + 1} of {result.total_pages}.)"
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.exception("Failed to extract PDF {}", path)
|
||||
return f"[error: failed to extract PDF: {e!s}]"
|
||||
|
||||
|
||||
def extract_pdf_pages(
|
||||
path: Path,
|
||||
*,
|
||||
pages: str | None = None,
|
||||
max_pages: int = _MAX_PDF_ATTACHMENT_PAGES,
|
||||
max_chars: int = _MAX_TEXT_LENGTH,
|
||||
) -> PdfExtraction:
|
||||
"""Extract a bounded PDF page range using the bundled pypdf reader."""
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(path, strict=False)
|
||||
total_pages = len(reader.pages)
|
||||
if total_pages == 0:
|
||||
return PdfExtraction("", 0, 0, -1)
|
||||
|
||||
start, end = _parse_pdf_page_range(pages, total_pages)
|
||||
end = min(end, start + max_pages - 1)
|
||||
collector = _TextCollector(max_chars)
|
||||
for index in range(start, end + 1):
|
||||
page = reader.pages[index]
|
||||
contents = page.get_contents()
|
||||
if contents is not None:
|
||||
stream_size = len(contents.get_data())
|
||||
if stream_size > _MAX_PDF_CONTENT_STREAM_SIZE:
|
||||
raise PdfSafetyError(
|
||||
f"page {index + 1} content stream exceeds "
|
||||
f"{_MAX_PDF_CONTENT_STREAM_SIZE // (1024 * 1024)} MB limit"
|
||||
)
|
||||
text = (page.extract_text() or "").strip()
|
||||
if text and not collector.add(f"--- Page {index + 1} ---\n{text}", separator="\n\n"):
|
||||
end = index
|
||||
break
|
||||
return PdfExtraction(collector.render(), total_pages, start, end)
|
||||
|
||||
|
||||
def _parse_pdf_page_range(pages: str | None, total_pages: int) -> tuple[int, int]:
|
||||
if not pages:
|
||||
return 0, total_pages - 1
|
||||
values = pages.strip().split("-")
|
||||
if len(values) not in {1, 2}:
|
||||
raise PdfPageRangeError(f"invalid page range: {pages}")
|
||||
try:
|
||||
start = int(values[0])
|
||||
end = int(values[-1])
|
||||
except ValueError as e:
|
||||
raise PdfPageRangeError(f"invalid page range: {pages}") from e
|
||||
if start < 1 or end < start or start > total_pages:
|
||||
raise PdfPageRangeError(f"invalid page range: {pages}")
|
||||
return start - 1, min(end, total_pages) - 1
|
||||
|
||||
|
||||
def _extract_docx(path: Path) -> str:
|
||||
"""Extract text from DOCX using python-docx."""
|
||||
try:
|
||||
@@ -103,9 +213,15 @@ def _extract_docx(path: Path) -> str:
|
||||
except ImportError:
|
||||
return "[error: python-docx not installed]"
|
||||
try:
|
||||
if error := _office_archive_error(path):
|
||||
return error
|
||||
doc = DocxDocument(path)
|
||||
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
|
||||
collector = _TextCollector(_MAX_TEXT_LENGTH)
|
||||
for paragraph in doc.paragraphs:
|
||||
text = paragraph.text.strip()
|
||||
if text and not collector.add(text, separator="\n\n"):
|
||||
break
|
||||
return collector.render()
|
||||
except Exception as e:
|
||||
logger.exception("Failed to extract DOCX {}", path)
|
||||
return f"[error: failed to extract DOCX: {e!s}]"
|
||||
@@ -118,19 +234,27 @@ def _extract_xlsx(path: Path) -> str:
|
||||
except ImportError:
|
||||
return "[error: openpyxl not installed]"
|
||||
try:
|
||||
if error := _office_archive_error(path):
|
||||
return error
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
try:
|
||||
sheets: list[str] = []
|
||||
collector = _TextCollector(_MAX_TEXT_LENGTH)
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
rows: list[str] = []
|
||||
wrote_header = False
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
|
||||
if row_text.strip():
|
||||
rows.append(row_text)
|
||||
if rows:
|
||||
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
|
||||
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
|
||||
if not wrote_header:
|
||||
if not collector.add(
|
||||
f"--- Sheet: {sheet_name} ---",
|
||||
separator="\n\n",
|
||||
):
|
||||
return collector.render()
|
||||
wrote_header = True
|
||||
if not collector.add(row_text, separator="\n"):
|
||||
return collector.render()
|
||||
return collector.render()
|
||||
finally:
|
||||
wb.close()
|
||||
except Exception as e:
|
||||
@@ -145,15 +269,21 @@ def _extract_pptx(path: Path) -> str:
|
||||
except ImportError:
|
||||
return "[error: python-pptx not installed]"
|
||||
try:
|
||||
if error := _office_archive_error(path):
|
||||
return error
|
||||
prs = PptxPresentation(path)
|
||||
slides: list[str] = []
|
||||
collector = _TextCollector(_MAX_TEXT_LENGTH)
|
||||
for i, slide in enumerate(prs.slides, 1):
|
||||
slide_text: list[str] = []
|
||||
for shape in slide.shapes:
|
||||
_collect_pptx_shape_text(shape, slide_text)
|
||||
if slide_text:
|
||||
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
||||
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
|
||||
if not collector.add(
|
||||
f"--- Slide {i} ---\n" + "\n".join(slide_text),
|
||||
separator="\n\n",
|
||||
):
|
||||
break
|
||||
return collector.render()
|
||||
except Exception as e:
|
||||
logger.exception("Failed to extract PPTX {}", path)
|
||||
return f"[error: failed to extract PPTX: {e!s}]"
|
||||
@@ -184,6 +314,28 @@ def _collect_pptx_shape_text(shape, out: list[str]) -> None:
|
||||
out.append(text)
|
||||
|
||||
|
||||
def _office_archive_error(path: Path) -> str | None:
|
||||
"""Reject oversized or encrypted OOXML containers before parsing XML."""
|
||||
try:
|
||||
with ZipFile(path) as archive:
|
||||
members = archive.infolist()
|
||||
except (BadZipFile, OSError) as e:
|
||||
return f"[error: invalid Office document: {e!s}]"
|
||||
if len(members) > _MAX_OFFICE_ARCHIVE_MEMBERS:
|
||||
return f"[error: Office document contains too many files ({len(members)})]"
|
||||
total_size = 0
|
||||
for member in members:
|
||||
if member.flag_bits & 0x1:
|
||||
return "[error: encrypted Office documents are not supported]"
|
||||
if member.file_size > _MAX_OFFICE_MEMBER_SIZE:
|
||||
return "[error: Office document contains an oversized internal file]"
|
||||
total_size += member.file_size
|
||||
if total_size > _MAX_OFFICE_UNCOMPRESSED_SIZE:
|
||||
limit_mb = _MAX_OFFICE_UNCOMPRESSED_SIZE / (1024 * 1024)
|
||||
return f"[error: Office document expands beyond the {limit_mb:g} MB safety limit]"
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text_file(path: Path) -> str:
|
||||
"""Extract text from a plain text file."""
|
||||
try:
|
||||
@@ -228,8 +380,6 @@ def _is_text_extension(ext: str) -> bool:
|
||||
# High-level helper: split media into images + extracted document text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
|
||||
def is_image_file(path: str) -> bool:
|
||||
"""Check whether *path* looks like an image file.
|
||||
|
||||
@@ -3,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``.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,6 +162,7 @@ class GatewayHTTPHandler:
|
||||
local_trigger_store: LocalTriggerStore | None = None,
|
||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||
channel_feature_action: Callable[..., Any] | None = None,
|
||||
log: Any = logger,
|
||||
) -> None:
|
||||
self.config = config
|
||||
@@ -194,6 +195,7 @@ class GatewayHTTPHandler:
|
||||
error_response=_http_error,
|
||||
runtime_surface=runtime_surface,
|
||||
runtime_capabilities=self._capabilities,
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
|
||||
def workspace_controls_available(self, connection: Any) -> bool:
|
||||
|
||||
+12
-1
@@ -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",
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Any, cast
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nanobot.cli import onboard as onboard_wizard
|
||||
from nanobot.cli.commands import _merge_missing_defaults
|
||||
from nanobot.cli.onboard import (
|
||||
_BACK_PRESSED,
|
||||
_configure_pydantic_model,
|
||||
@@ -22,18 +21,19 @@ from nanobot.cli.onboard import (
|
||||
_input_text,
|
||||
run_onboard,
|
||||
)
|
||||
from nanobot.config.loader import merge_missing_defaults
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.utils.helpers import sync_workspace_templates
|
||||
|
||||
|
||||
class TestMergeMissingDefaults:
|
||||
"""Tests for _merge_missing_defaults recursive config merging."""
|
||||
"""Tests for recursive config default merging."""
|
||||
|
||||
def test_adds_missing_top_level_keys(self):
|
||||
existing = {"a": 1}
|
||||
defaults = {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
result = _merge_missing_defaults(existing, defaults)
|
||||
result = merge_missing_defaults(existing, defaults)
|
||||
|
||||
assert result == {"a": 1, "b": 2, "c": 3}
|
||||
|
||||
@@ -41,7 +41,7 @@ class TestMergeMissingDefaults:
|
||||
existing = {"a": "custom_value"}
|
||||
defaults = {"a": "default_value"}
|
||||
|
||||
result = _merge_missing_defaults(existing, defaults)
|
||||
result = merge_missing_defaults(existing, defaults)
|
||||
|
||||
assert result == {"a": "custom_value"}
|
||||
|
||||
@@ -63,7 +63,7 @@ class TestMergeMissingDefaults:
|
||||
}
|
||||
}
|
||||
|
||||
result = _merge_missing_defaults(existing, defaults)
|
||||
result = merge_missing_defaults(existing, defaults)
|
||||
|
||||
assert result == {
|
||||
"level1": {
|
||||
@@ -76,19 +76,19 @@ class TestMergeMissingDefaults:
|
||||
}
|
||||
|
||||
def test_returns_existing_if_not_dict(self):
|
||||
assert _merge_missing_defaults("string", {"a": 1}) == "string"
|
||||
assert _merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
|
||||
assert _merge_missing_defaults(None, {"a": 1}) is None
|
||||
assert _merge_missing_defaults(42, {"a": 1}) == 42
|
||||
assert merge_missing_defaults("string", {"a": 1}) == "string"
|
||||
assert merge_missing_defaults([1, 2, 3], {"a": 1}) == [1, 2, 3]
|
||||
assert merge_missing_defaults(None, {"a": 1}) is None
|
||||
assert merge_missing_defaults(42, {"a": 1}) == 42
|
||||
|
||||
def test_returns_existing_if_defaults_not_dict(self):
|
||||
assert _merge_missing_defaults({"a": 1}, "string") == {"a": 1}
|
||||
assert _merge_missing_defaults({"a": 1}, None) == {"a": 1}
|
||||
assert merge_missing_defaults({"a": 1}, "string") == {"a": 1}
|
||||
assert merge_missing_defaults({"a": 1}, None) == {"a": 1}
|
||||
|
||||
def test_handles_empty_dicts(self):
|
||||
assert _merge_missing_defaults({}, {"a": 1}) == {"a": 1}
|
||||
assert _merge_missing_defaults({"a": 1}, {}) == {"a": 1}
|
||||
assert _merge_missing_defaults({}, {}) == {}
|
||||
assert merge_missing_defaults({}, {"a": 1}) == {"a": 1}
|
||||
assert merge_missing_defaults({"a": 1}, {}) == {"a": 1}
|
||||
assert merge_missing_defaults({}, {}) == {}
|
||||
|
||||
def test_backfills_channel_config(self):
|
||||
"""Real-world scenario: backfill missing channel fields."""
|
||||
@@ -105,7 +105,7 @@ class TestMergeMissingDefaults:
|
||||
"allowFrom": [],
|
||||
}
|
||||
|
||||
result = _merge_missing_defaults(existing_channel, default_channel)
|
||||
result = merge_missing_defaults(existing_channel, default_channel)
|
||||
|
||||
assert result["msgFormat"] == "plain"
|
||||
assert result["allowFrom"] == []
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.manager import ChannelManager
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
|
||||
class _HotChannel(BaseChannel):
|
||||
name = "hot"
|
||||
display_name = "Hot"
|
||||
|
||||
def __init__(self, config, bus):
|
||||
super().__init__(config, bus)
|
||||
self.started = asyncio.Event()
|
||||
self.stopped = asyncio.Event()
|
||||
|
||||
async def start(self):
|
||||
self._running = True
|
||||
self.started.set()
|
||||
await self.stopped.wait()
|
||||
|
||||
async def stop(self):
|
||||
self._running = False
|
||||
self.stopped.set()
|
||||
|
||||
async def send(self, msg): # pragma: no cover - not used by this test
|
||||
raise AssertionError("send should not be called")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch):
|
||||
disabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"hot": {"enabled": False},
|
||||
}
|
||||
})
|
||||
enabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"hot": {"enabled": True},
|
||||
}
|
||||
})
|
||||
|
||||
import nanobot.channels.registry as registry
|
||||
|
||||
def discover_enabled(enabled_names, **_kwargs):
|
||||
return {"hot": _HotChannel} if "hot" in enabled_names else {}
|
||||
|
||||
configs = iter([enabled, disabled])
|
||||
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
||||
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
||||
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
|
||||
|
||||
manager = ChannelManager(disabled, MessageBus())
|
||||
manager._started = True
|
||||
|
||||
enabled_result = await manager.apply_channel_feature_action("enable", "hot")
|
||||
|
||||
assert enabled_result["handled"] is True
|
||||
assert enabled_result["requires_restart"] is False
|
||||
channel = manager.channels["hot"]
|
||||
await asyncio.wait_for(channel.started.wait(), timeout=1)
|
||||
assert channel.is_running is True
|
||||
|
||||
disabled_result = await manager.apply_channel_feature_action("disable", "hot")
|
||||
|
||||
assert disabled_result["handled"] is True
|
||||
assert disabled_result["requires_restart"] is False
|
||||
assert "hot" not in manager.channels
|
||||
assert channel.is_running is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_fails(monkeypatch):
|
||||
enabled = Config.model_validate({
|
||||
"channels": {
|
||||
"websocket": {"enabled": False},
|
||||
"hot": {"enabled": True},
|
||||
}
|
||||
})
|
||||
|
||||
import nanobot.channels.registry as registry
|
||||
|
||||
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
||||
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
||||
monkeypatch.setattr(
|
||||
registry,
|
||||
"discover_enabled",
|
||||
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
|
||||
|
||||
manager = ChannelManager(enabled, MessageBus())
|
||||
old_channel = manager.channels["hot"]
|
||||
old_channel._running = True
|
||||
|
||||
def fail_build(*_args, **_kwargs):
|
||||
raise RuntimeError("invalid replacement config")
|
||||
|
||||
monkeypatch.setattr(manager, "_build_channel", fail_build)
|
||||
|
||||
result = await manager.apply_channel_feature_action("enable", "hot")
|
||||
|
||||
assert result["requires_restart"] is True
|
||||
assert manager.channels["hot"] is old_channel
|
||||
assert old_channel.is_running is True
|
||||
assert not old_channel.stopped.is_set()
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
from nanobot.channels._feishu_ws import FeishuWsRunner
|
||||
|
||||
|
||||
def test_concurrent_loop_initialization_starts_one_thread(monkeypatch) -> None:
|
||||
runner = FeishuWsRunner()
|
||||
created_loops: list[asyncio.AbstractEventLoop] = []
|
||||
release_start = threading.Event()
|
||||
|
||||
def fake_run_loop() -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
created_loops.append(loop)
|
||||
assert release_start.wait(timeout=2)
|
||||
runner._loop = loop
|
||||
runner._ready.set()
|
||||
|
||||
monkeypatch.setattr(runner, "_run_loop", fake_run_loop)
|
||||
loops: list[asyncio.AbstractEventLoop] = []
|
||||
threads = [threading.Thread(target=lambda: loops.append(runner._ensure_loop())) for _ in range(2)]
|
||||
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
release_start.set()
|
||||
for thread in threads:
|
||||
thread.join(timeout=2)
|
||||
|
||||
assert len(created_loops) == 1
|
||||
assert loops == [created_loops[0], created_loops[0]]
|
||||
created_loops[0].close()
|
||||
@@ -76,6 +76,7 @@ def _make_handler(
|
||||
local_trigger_store: LocalTriggerStore | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
local_trigger_pending_ids: Any | None = None,
|
||||
channel_feature_action: Any | None = None,
|
||||
) -> GatewayServices:
|
||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||
workspace = workspace_path or Path.cwd()
|
||||
@@ -93,6 +94,7 @@ def _make_handler(
|
||||
local_trigger_store=local_trigger_store,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +110,7 @@ def _ch(
|
||||
local_trigger_store: LocalTriggerStore | None = None,
|
||||
cron_pending_job_ids: Any | None = None,
|
||||
local_trigger_pending_ids: Any | None = None,
|
||||
channel_feature_action: Any | None = None,
|
||||
**extra: Any,
|
||||
) -> WebSocketChannel:
|
||||
cfg: dict[str, Any] = {
|
||||
@@ -129,6 +132,7 @@ def _ch(
|
||||
local_trigger_store=local_trigger_store,
|
||||
cron_pending_job_ids=cron_pending_job_ids,
|
||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
@@ -650,6 +654,128 @@ async def test_nanobot_feature_routes_require_token_and_enable(
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_routes_require_token_and_approve_or_deny(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pending = [
|
||||
{
|
||||
"code": "ABCD-EFGH",
|
||||
"channel": "feishu",
|
||||
"sender_id": "ou_123",
|
||||
"created_at": 1_000.0,
|
||||
"expires_at": 1_600.0,
|
||||
}
|
||||
]
|
||||
approved: list[str] = []
|
||||
denied: list[str] = []
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.list_pending", lambda: list(pending))
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.approve_code",
|
||||
lambda code: approved.append(code) or ("feishu", "ou_123") if code == "ABCD-EFGH" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.deny_code",
|
||||
lambda code: denied.append(code) or code == "ABCD-EFGH",
|
||||
)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
|
||||
denied_response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(path="/api/settings/pairing"),
|
||||
"/api/settings/pairing",
|
||||
)
|
||||
assert denied_response is not None
|
||||
assert denied_response.status_code == 401
|
||||
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
listed = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing"),
|
||||
"/api/settings/pairing",
|
||||
)
|
||||
assert listed is not None
|
||||
assert listed.status_code == 200
|
||||
body = json.loads(listed.body.decode())
|
||||
assert body["requests"][0]["code"] == "ABCD-EFGH"
|
||||
assert body["requests"][0]["channel"] == "feishu"
|
||||
assert body["requests"][0]["sender_id"] == "ou_123"
|
||||
assert body["requests"][0]["created_at_ms"] == 1_000_000
|
||||
assert body["requests"][0]["expires_at_ms"] == 1_600_000
|
||||
|
||||
approved_response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing/approve?code=ABCD-EFGH"),
|
||||
"/api/settings/pairing/approve",
|
||||
)
|
||||
assert approved_response is not None
|
||||
assert approved_response.status_code == 200
|
||||
body = json.loads(approved_response.body.decode())
|
||||
assert body["last_action"]["action"] == "approve"
|
||||
assert body["last_action"]["sender_id"] == "ou_123"
|
||||
assert approved == ["ABCD-EFGH"]
|
||||
|
||||
denied_action = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing/deny?code=ABCD-EFGH"),
|
||||
"/api/settings/pairing/deny",
|
||||
)
|
||||
assert denied_action is not None
|
||||
assert denied_action.status_code == 200
|
||||
assert json.loads(denied_action.body.decode())["last_action"]["action"] == "deny"
|
||||
assert denied == ["ABCD-EFGH"]
|
||||
|
||||
missing_code = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(auth, path="/api/settings/pairing/approve"),
|
||||
"/api/settings/pairing/approve",
|
||||
)
|
||||
assert missing_code is not None
|
||||
assert missing_code.status_code == 400
|
||||
assert "Missing pairing code" in missing_code.body.decode()
|
||||
|
||||
|
||||
def test_api_service_settings_read_api_key_from_private_header(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
request = _FakeReq(
|
||||
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": "secret-token"})},
|
||||
path="/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
||||
)
|
||||
|
||||
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
||||
|
||||
assert query == {
|
||||
"host": ["0.0.0.0"],
|
||||
"port": ["8900"],
|
||||
"timeout": ["120"],
|
||||
"api_key": ["secret-token"],
|
||||
}
|
||||
|
||||
|
||||
def test_api_service_settings_reject_invalid_private_header(bus: MagicMock) -> None:
|
||||
from nanobot.webui.settings_api import WebUISettingsError
|
||||
|
||||
channel = _ch(bus)
|
||||
request = _FakeReq(
|
||||
{"X-Nanobot-API-Service-Values": json.dumps({"api_key": 123})},
|
||||
path="/api/settings/api-service/start?host=127.0.0.1",
|
||||
)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="API key must be a string"):
|
||||
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
||||
|
||||
query_secret = _FakeReq(
|
||||
path="/api/settings/api-service/start?host=127.0.0.1&api_key=secret-token",
|
||||
)
|
||||
with pytest.raises(WebUISettingsError, match="private header"):
|
||||
channel.gateway.http.settings_routes._parse_api_service_settings_query(query_secret)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nanobot_feature_remote_install_requires_opt_in(
|
||||
bus: MagicMock,
|
||||
@@ -733,6 +859,506 @@ async def test_nanobot_feature_local_install_allowed_by_default(
|
||||
] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nanobot_feature_channel_action_can_apply_without_restart(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
_stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"])
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
||||
calls.append((action, name))
|
||||
return {
|
||||
"handled": True,
|
||||
"ok": True,
|
||||
"requires_restart": False,
|
||||
"message": "Matrix channel applied without restart.",
|
||||
}
|
||||
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
port=_free_port(),
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
request = _FakeReq(
|
||||
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
|
||||
path="/api/settings/nanobot-features/enable?name=matrix",
|
||||
)
|
||||
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
request,
|
||||
"/api/settings/nanobot-features/enable",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert calls == [("enable", "matrix")]
|
||||
assert body["requires_restart"] is False
|
||||
assert body["restart_required_sections"] == []
|
||||
assert body["last_action"]["hot_reload"] is True
|
||||
assert body["last_action"]["message"].endswith("Matrix channel applied without restart.")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feishu_connect_routes_write_config_and_hot_reload(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"_begin_registration",
|
||||
lambda _domain: {
|
||||
"device_code": "device",
|
||||
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
|
||||
"interval": 2,
|
||||
"expire_in": 600,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"poll_registration_once",
|
||||
lambda *, device_code, domain: {
|
||||
"status": "succeeded",
|
||||
"app_id": "cli_app",
|
||||
"app_secret": "secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"fetch_feishu_app_identity",
|
||||
lambda app_id, app_secret, domain: {
|
||||
"displayName": "Voraflare Bot",
|
||||
"avatarUrl": "https://example.com/feishu.png",
|
||||
"identityFetchedAt": "2026-07-06T00:00:00Z",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.settings_routes.nanobot_features_action",
|
||||
lambda _action, _query, *, allow_install=True: {
|
||||
"features": [{
|
||||
"name": "feishu",
|
||||
"display_name": "Feishu",
|
||||
"type": "channel",
|
||||
"enabled": True,
|
||||
"installed": True,
|
||||
"ready": True,
|
||||
"status": "enabled",
|
||||
"install_supported": True,
|
||||
"requires_restart": True,
|
||||
}],
|
||||
"enabled_count": 1,
|
||||
"requires_restart": True,
|
||||
"last_action": {"ok": True, "message": "Enabled channel 'feishu'", "enabled": True},
|
||||
},
|
||||
)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
||||
calls.append((action, name))
|
||||
return {
|
||||
"handled": True,
|
||||
"ok": True,
|
||||
"requires_restart": False,
|
||||
"message": "Feishu channel applied without restart.",
|
||||
}
|
||||
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
port=_free_port(),
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
auth = {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}
|
||||
|
||||
started = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
auth,
|
||||
path="/api/settings/channels/feishu/connect/start?domain=feishu&instance_id=default",
|
||||
),
|
||||
"/api/settings/channels/feishu/connect/start",
|
||||
)
|
||||
|
||||
assert started is not None
|
||||
assert started.status_code == 200
|
||||
start_body = json.loads(started.body.decode())
|
||||
assert start_body["status"] == "pending"
|
||||
assert start_body["instance_id"] == "default"
|
||||
assert start_body["qr_url"].startswith("https://accounts.feishu.cn/")
|
||||
|
||||
polled = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
auth,
|
||||
path=f"/api/settings/channels/feishu/connect/poll?session_id={start_body['session_id']}",
|
||||
),
|
||||
"/api/settings/channels/feishu/connect/poll",
|
||||
)
|
||||
|
||||
assert polled is not None
|
||||
assert polled.status_code == 200
|
||||
body = json.loads(polled.body.decode())
|
||||
assert body["status"] == "succeeded"
|
||||
assert body["instance_id"] == "default"
|
||||
assert "app_secret" not in body
|
||||
assert calls == [("enable", "feishu")]
|
||||
assert body["nanobot_features"]["requires_restart"] is False
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["feishu"]["instances"][0]["id"] == "default"
|
||||
assert data["channels"]["feishu"]["instances"][0]["appId"] == "cli_app"
|
||||
assert data["channels"]["feishu"]["instances"][0]["appSecret"] == "secret"
|
||||
assert data["channels"]["feishu"]["instances"][0]["enabled"] is True
|
||||
assert data["channels"]["feishu"]["instances"][0]["displayName"] == "Voraflare Bot"
|
||||
assert data["channels"]["feishu"]["instances"][0]["avatarUrl"] == "https://example.com/feishu.png"
|
||||
|
||||
|
||||
def test_feishu_connect_create_appends_instance(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.channels import feishu as feishu_module
|
||||
from nanobot.config import loader
|
||||
from nanobot.webui.channel_connect import FeishuConnectStore
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({
|
||||
"channels": {
|
||||
"feishu": {
|
||||
"instances": [{
|
||||
"id": "default",
|
||||
"name": "nanobot",
|
||||
"enabled": True,
|
||||
"appId": "cli_default",
|
||||
"appSecret": "default-secret",
|
||||
}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
monkeypatch.setattr(feishu_module, "_init_registration", lambda _domain: None)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"_begin_registration",
|
||||
lambda _domain: {
|
||||
"device_code": "device",
|
||||
"qr_url": "https://accounts.feishu.cn/login?device_code=device",
|
||||
"interval": 2,
|
||||
"expire_in": 600,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"poll_registration_once",
|
||||
lambda *, device_code, domain: {
|
||||
"status": "succeeded",
|
||||
"app_id": "cli_new",
|
||||
"app_secret": "new-secret",
|
||||
"domain": "feishu",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
feishu_module,
|
||||
"fetch_feishu_app_identity",
|
||||
lambda app_id, app_secret, domain: {
|
||||
"displayName": f"Assistant {app_id}",
|
||||
"avatarUrl": f"https://example.com/{app_id}.png",
|
||||
"identityFetchedAt": "2026-07-06T00:00:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
store = FeishuConnectStore()
|
||||
started = store.start(mode="create")
|
||||
polled = store.poll(started["session_id"])
|
||||
|
||||
assert polled["status"] == "succeeded"
|
||||
assert polled["instance_id"] != "default"
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
instances = data["channels"]["feishu"]["instances"]
|
||||
assert [item["id"] for item in instances] == ["default", polled["instance_id"]]
|
||||
assert instances[0]["appId"] == "cli_default"
|
||||
assert instances[1]["appId"] == "cli_new"
|
||||
assert instances[0].get("displayName") is None
|
||||
assert instances[1]["displayName"] == "Assistant cli_new"
|
||||
assert instances[1]["avatarUrl"] == "https://example.com/cli_new.png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
def fake_feature_action(
|
||||
action: str,
|
||||
query: dict[str, list[str]],
|
||||
*,
|
||||
allow_install: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
assert action == "enable"
|
||||
assert query == {"name": ["discord"]}
|
||||
cfg = loader.load_config()
|
||||
section = dict(getattr(cfg.channels, "discord", {}) or {})
|
||||
section["enabled"] = True
|
||||
setattr(cfg.channels, "discord", section)
|
||||
loader.save_config(cfg)
|
||||
return {
|
||||
"features": [{
|
||||
"name": "discord",
|
||||
"display_name": "Discord",
|
||||
"type": "channel",
|
||||
"enabled": True,
|
||||
"installed": True,
|
||||
"ready": True,
|
||||
"status": "enabled",
|
||||
"install_supported": True,
|
||||
"requires_restart": True,
|
||||
}],
|
||||
"enabled_count": 1,
|
||||
"requires_restart": True,
|
||||
"last_action": {"ok": True, "message": "Enabled channel 'discord'", "enabled": True},
|
||||
}
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.settings_routes.nanobot_features_action", fake_feature_action)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
||||
calls.append((action, name))
|
||||
cfg = loader.load_config()
|
||||
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
|
||||
return {
|
||||
"handled": True,
|
||||
"ok": True,
|
||||
"requires_restart": False,
|
||||
"message": "Discord channel applied without restart.",
|
||||
}
|
||||
|
||||
channel = _ch(
|
||||
bus,
|
||||
session_manager=_seed_session(tmp_path),
|
||||
port=_free_port(),
|
||||
channel_feature_action=channel_feature_action,
|
||||
)
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.discord.token": "discord-token",
|
||||
"channels.discord.allowChannels": "123, 456",
|
||||
"channels.discord.groupPolicy": "open",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=discord&enable=true",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["saved"] is True
|
||||
assert body["name"] == "discord"
|
||||
assert "discord-token" not in response.body.decode()
|
||||
assert calls == [("enable", "discord")]
|
||||
assert body["nanobot_features"]["requires_restart"] is False
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["discord"] == {
|
||||
"token": "discord-token",
|
||||
"allowChannels": ["123", "456"],
|
||||
"groupPolicy": "open",
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_preserves_existing_channel_values(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
setattr(
|
||||
config.channels,
|
||||
"discord",
|
||||
{
|
||||
"enabled": True,
|
||||
"token": "old-discord-token",
|
||||
"allowChannels": ["old-channel"],
|
||||
"groupPolicy": "mention",
|
||||
"customExtra": "keep-me",
|
||||
"nested": {"value": 42},
|
||||
},
|
||||
)
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.discord.token": "",
|
||||
"channels.discord.allowChannels": "new-channel",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=discord",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert body["saved_keys"] == ["channels.discord.allowChannels"]
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["discord"] == {
|
||||
"enabled": True,
|
||||
"token": "old-discord-token",
|
||||
"allowChannels": ["new-channel"],
|
||||
"groupPolicy": "mention",
|
||||
"customExtra": "keep-me",
|
||||
"nested": {"value": 42},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_saves_matrix_device_id_without_replacing_token(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
setattr(
|
||||
config.channels,
|
||||
"matrix",
|
||||
{
|
||||
"enabled": False,
|
||||
"homeserver": "https://matrix.example",
|
||||
"userId": "@nanobot:matrix.example",
|
||||
"accessToken": "saved-token",
|
||||
},
|
||||
)
|
||||
loader.save_config(config, config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.matrix.accessToken": "",
|
||||
"channels.matrix.deviceId": "DEVICE-ID",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=matrix",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["matrix"]["accessToken"] == "saved-token"
|
||||
assert data["channels"]["matrix"]["deviceId"] == "DEVICE-ID"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_configure_route_saves_mattermost_setup(
|
||||
bus: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from nanobot.config import loader
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
loader.save_config(Config(), config_path)
|
||||
monkeypatch.setattr(loader, "_current_config_path", config_path)
|
||||
|
||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
||||
token = channel.gateway.tokens.issue_api_token(300)
|
||||
response = await channel.gateway.http.settings_routes.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Host": "127.0.0.1:8765",
|
||||
"X-Nanobot-Channel-Values": json.dumps(
|
||||
{
|
||||
"channels.mattermost.serverUrl": "https://chat.example.com",
|
||||
"channels.mattermost.token": "mattermost-token",
|
||||
"channels.mattermost.teamId": "platform",
|
||||
}
|
||||
),
|
||||
},
|
||||
path="/api/settings/channels/configure?name=mattermost",
|
||||
),
|
||||
"/api/settings/channels/configure",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.status_code == 200
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
assert data["channels"]["mattermost"] == {
|
||||
"serverUrl": "https://chat.example.com",
|
||||
"token": "mattermost-token",
|
||||
"teamId": "platform",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in(
|
||||
bus: MagicMock,
|
||||
|
||||
+180
-1
@@ -7,6 +7,7 @@ from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
@@ -1607,6 +1608,12 @@ def _patch_webui_provider_ready(monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.providers.factory.build_provider_snapshot", _snapshot)
|
||||
|
||||
|
||||
def _patch_gateway_ports_free(monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_a, **_kw: False)
|
||||
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_a, **_kw: False)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_a, **_kw: False)
|
||||
|
||||
|
||||
def _patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config: Config,
|
||||
@@ -1643,6 +1650,7 @@ def _patch_cli_command_runtime(
|
||||
"nanobot.providers.factory.load_provider_snapshot",
|
||||
lambda _config_path=None: _test_provider_snapshot(provider_factory(config), config),
|
||||
)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
|
||||
if message_bus is not None:
|
||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", message_bus)
|
||||
@@ -1701,11 +1709,17 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
||||
assert len(websocket["tokenIssueSecret"]) >= 32
|
||||
assert data["agents"]["defaults"]["workspace"] == str(workspace)
|
||||
assert seen["templates"] == workspace
|
||||
assert seen["gateway_kwargs"] == {"port": 18888, "open_browser_url": None}
|
||||
assert seen["gateway_kwargs"] == {
|
||||
"port": 18888,
|
||||
"open_browser_url": None,
|
||||
"webui_bundle_mode": "auto",
|
||||
}
|
||||
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
|
||||
assert "bootstrap secret was generated" in compact_output
|
||||
assert "channels.websocket.tokenIssueSecret" in compact_output
|
||||
assert "rerun without --no-open" in compact_output
|
||||
assert "nanobot is running in this terminal" in compact_output
|
||||
assert "Press Ctrl+C here to stop nanobot" in compact_output
|
||||
|
||||
|
||||
def test_webui_yes_refuses_missing_provider_setup(monkeypatch, tmp_path: Path) -> None:
|
||||
@@ -1732,6 +1746,10 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
|
||||
seen: dict[str, object] = {}
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
@@ -1785,6 +1803,21 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
|
||||
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
|
||||
assert "bootstrapSecret=<redacted>" in compact_output
|
||||
assert "bootstrapSecret=" in opened_url
|
||||
assert "Closing the browser does not stop channels or automations" in compact_output
|
||||
assert "nanobot gateway stop --config" in compact_output
|
||||
|
||||
|
||||
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
|
||||
opened: list[str] = []
|
||||
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
|
||||
monkeypatch.setattr("webbrowser.open", lambda value: opened.append(value))
|
||||
|
||||
cli_commands._open_webui_browser(url, wait=False)
|
||||
|
||||
assert opened == [url]
|
||||
output = _strip_ansi(capsys.readouterr().out)
|
||||
assert "bootstrapSecret=<redacted>" in output
|
||||
assert "super-secret" not in output
|
||||
|
||||
|
||||
def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
||||
@@ -1799,6 +1832,10 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
||||
seen: dict[str, object] = {}
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._prepare_webui_bundle_for_gateway",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
def _status(options: GatewayStartOptions) -> GatewayStatus:
|
||||
return GatewayStatus(
|
||||
@@ -1861,6 +1898,125 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
||||
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
|
||||
|
||||
|
||||
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
seen: dict[str, object] = {}
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._open_webui_browser",
|
||||
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._run_gateway",
|
||||
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["runtime_kwargs"] = kwargs
|
||||
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._attach_to_background_gateway",
|
||||
lambda runtime: seen.__setitem__("attached_runtime", runtime),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway is already running; attaching to the existing WebUI" in result.stdout
|
||||
assert isinstance(seen["attached_runtime"], _FakeRuntime)
|
||||
opened_url = seen["opened_url"]
|
||||
assert isinstance(opened_url, str)
|
||||
parsed = urlparse(opened_url)
|
||||
assert f"{parsed.scheme}://{parsed.netloc}" == "http://127.0.0.1:8765"
|
||||
fragment = parsed.fragment.removeprefix("/?")
|
||||
assert parse_qs(fragment).get("bootstrapSecret")
|
||||
assert seen["open_kwargs"] == {"wait": False}
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
|
||||
stopped = False
|
||||
|
||||
class _FakeRuntime:
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
def stop(self):
|
||||
nonlocal stopped
|
||||
stopped = True
|
||||
return SimpleNamespace(ok=True, message="gateway_stopped")
|
||||
|
||||
def _interrupt(_seconds: float) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.commands.time.sleep", _interrupt)
|
||||
|
||||
cli_commands._attach_to_background_gateway(_FakeRuntime())
|
||||
|
||||
assert stopped is True
|
||||
output = capsys.readouterr().out
|
||||
assert "Closing the browser does not stop channels or automations" in output
|
||||
assert "Press Ctrl+C here to stop nanobot" in output
|
||||
assert "Gateway stopped" in output
|
||||
|
||||
|
||||
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._open_webui_browser", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._attach_to_background_gateway",
|
||||
lambda _runtime: pytest.fail("unmanaged gateway must not be attached"),
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
def status(self):
|
||||
return SimpleNamespace(running=False)
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "controlled by another foreground command" in result.stdout
|
||||
|
||||
|
||||
def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.cli.commands._gateway_health_ready", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr("nanobot.cli.commands._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr("nanobot.cli.commands._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands._run_gateway",
|
||||
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "nanobot cannot start because one of its local ports is already in use" in result.stdout
|
||||
assert "--port" in result.stdout
|
||||
assert "--gateway-port" in result.stdout
|
||||
|
||||
|
||||
def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -> None:
|
||||
pytest.importorskip("aiohttp")
|
||||
|
||||
@@ -1998,6 +2154,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
@@ -2124,6 +2281,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.providers.factory.build_provider_snapshot",
|
||||
lambda _config: _test_provider_snapshot(provider, _config),
|
||||
@@ -3062,6 +3220,27 @@ def test_serve_rejects_wildcard_host_without_api_key(monkeypatch, tmp_path: Path
|
||||
assert "api_app" not in seen
|
||||
|
||||
|
||||
def test_serve_rejects_specific_network_interface_without_api_key(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config_file = _write_instance_config(tmp_path)
|
||||
config = Config()
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
_patch_serve_runtime(monkeypatch, config, seen)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["serve", "--config", str(config_file), "--host", "192.168.1.10"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "api_key" in result.stdout
|
||||
assert "prevent unauthenticated access" in result.stdout
|
||||
assert "api_app" not in seen
|
||||
|
||||
|
||||
def test_channels_login_requires_channel_name() -> None:
|
||||
result = runner.invoke(app, ["channels", "login"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]]
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_docs_version,
|
||||
_model_catalog_kind,
|
||||
_oauth_provider_status,
|
||||
create_model_configuration,
|
||||
@@ -20,6 +21,7 @@ from nanobot.webui.settings_api import (
|
||||
settings_payload,
|
||||
settings_usage_payload,
|
||||
update_agent_settings,
|
||||
update_api_settings,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
@@ -31,6 +33,100 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||
|
||||
|
||||
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
|
||||
assert _docs_version("0.2.3") == "0.2.3"
|
||||
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
|
||||
assert _docs_version("0.2.3.dev0") == "latest"
|
||||
assert _docs_version("0.2.3+editable") == "latest"
|
||||
|
||||
|
||||
def test_settings_payload_includes_versioned_docs(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr("nanobot.webui.settings_api.__version__", "0.2.3")
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
assert payload["docs"] == {
|
||||
"version": "0.2.3",
|
||||
"base_url": "https://nanobot.wiki/docs/0.2.3",
|
||||
"chat_apps_url": "https://nanobot.wiki/docs/0.2.3/getting-started/chat-apps",
|
||||
"latest_url": "https://nanobot.wiki/docs/latest",
|
||||
}
|
||||
|
||||
|
||||
def test_settings_payload_includes_relocated_capabilities(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.api.port = 9910
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
|
||||
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
|
||||
|
||||
payload = settings_payload()
|
||||
|
||||
assert payload["api"]["port"] == 9910
|
||||
assert payload["api"]["api_key_hint"] is None
|
||||
assert payload["observability"]["provider"] == "langfuse"
|
||||
assert payload["observability"]["configured"] is True
|
||||
|
||||
|
||||
def test_update_api_settings_requires_key_for_network_access(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="API key"):
|
||||
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
|
||||
|
||||
payload = update_api_settings({
|
||||
"host": ["0.0.0.0"],
|
||||
"port": ["9900"],
|
||||
"api_key": ["secret-token"],
|
||||
})
|
||||
saved = load_config(config_path)
|
||||
assert saved.api.host == "0.0.0.0"
|
||||
assert saved.api.port == 9900
|
||||
assert saved.api.api_key == "secret-token"
|
||||
assert payload["api"]["api_key_hint"]
|
||||
|
||||
|
||||
def test_update_api_settings_requires_key_for_specific_network_interface(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
with pytest.raises(WebUISettingsError, match="API key"):
|
||||
update_api_settings({"host": ["192.168.1.10"], "port": ["8900"]})
|
||||
|
||||
|
||||
def test_update_api_settings_allows_alternate_loopback_without_key(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
|
||||
|
||||
assert load_config(config_path).api.host == "127.0.0.2"
|
||||
|
||||
|
||||
def _dynamic_provider_config(
|
||||
*,
|
||||
api_base: str = DYNAMIC_PROVIDER_API_BASE,
|
||||
@@ -348,6 +444,42 @@ def test_settings_payload_includes_dynamic_custom_provider(
|
||||
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == DYNAMIC_PROVIDER_API_BASE
|
||||
|
||||
|
||||
def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = settings_payload()
|
||||
opencode_rows = [row for row in payload["providers"] if row["label"].startswith("OpenCode")]
|
||||
|
||||
assert [(row["name"], row["label"]) for row in opencode_rows] == [
|
||||
("opencode", "OpenCode Zen"),
|
||||
("opencode_go", "OpenCode Go"),
|
||||
]
|
||||
|
||||
|
||||
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate({
|
||||
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "opencode_zen",
|
||||
"model": "opencode/deepseek-v4-pro",
|
||||
}
|
||||
},
|
||||
})
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = settings_payload()
|
||||
zen_rows = [row for row in payload["providers"] if row["label"] == "OpenCode Zen"]
|
||||
|
||||
assert len(zen_rows) == 1
|
||||
assert zen_rows[0]["name"] == "opencode_zen"
|
||||
assert zen_rows[0]["configured"] is True
|
||||
|
||||
|
||||
def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfigured(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -952,6 +1084,26 @@ def test_provider_models_payload_fetches_openai_compatible_models(
|
||||
assert payload["models"][1]["context_window"] == 65536
|
||||
|
||||
|
||||
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
|
||||
payload = provider_models_payload({"provider": ["openai_codex"]})
|
||||
|
||||
assert payload["status"] == "available"
|
||||
assert payload["catalog_kind"] == "builtin"
|
||||
assert payload["model_count"] == 7
|
||||
assert payload["models"][0] == {
|
||||
"id": "openai-codex/gpt-5.6-sol",
|
||||
"label": "GPT-5.6-Sol",
|
||||
"description": "Latest frontier agentic coding model.",
|
||||
"owned_by": "OpenAI Codex",
|
||||
"context_window": 372000,
|
||||
}
|
||||
assert [model["id"] for model in payload["models"][:3]] == [
|
||||
"openai-codex/gpt-5.6-sol",
|
||||
"openai-codex/gpt-5.6-terra",
|
||||
"openai-codex/gpt-5.6-luna",
|
||||
]
|
||||
|
||||
|
||||
def test_provider_models_payload_fetches_dynamic_custom_provider_models(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1044,6 +1196,7 @@ def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
|
||||
assert _model_catalog_kind(find_by_name("skywork")) == "official"
|
||||
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
|
||||
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
|
||||
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
|
||||
|
||||
|
||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||
|
||||
+235
-4
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"diff": "^9.0.0",
|
||||
"i18next": "^26.0.6",
|
||||
"lucide-react": "^0.469.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
@@ -41,6 +42,7 @@
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
|
||||
+540
-6
@@ -6,7 +6,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Moon, PanelLeft, Sun } from "lucide-react";
|
||||
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DeleteConfirm } from "@/components/DeleteConfirm";
|
||||
import { RenameChatDialog } from "@/components/RenameChatDialog";
|
||||
@@ -20,7 +20,9 @@ import { useSessions } from "@/hooks/useSessions";
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import { useSidebarState } from "@/hooks/useSidebarState";
|
||||
import { useSkills } from "@/hooks/useSkills";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BootstrapAuthRequiredError,
|
||||
@@ -38,6 +40,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
ChatSummary,
|
||||
RuntimeSurface,
|
||||
PairingRequestInfo,
|
||||
SessionAutomationJob,
|
||||
SettingsPayload,
|
||||
WorkspaceScopePayload,
|
||||
@@ -45,7 +48,12 @@ import type {
|
||||
} from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { fetchSettings, fetchWorkspaces } from "@/lib/api";
|
||||
import {
|
||||
fetchPairingRequests,
|
||||
fetchSettings,
|
||||
fetchWorkspaces,
|
||||
runPairingAction,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
createRuntimeHost,
|
||||
getHostApi,
|
||||
@@ -70,11 +78,15 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
|
||||
const SESSION_UPDATES_STORAGE_KEY = "nanobot-webui.sidebar.session-updates.v1";
|
||||
const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const RESTART_ROUTE_KEY = "nanobot-webui.restartRoute";
|
||||
const RESTART_ROUTE_TTL_MS = 5 * 60 * 1000;
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
const SIDEBAR_RAIL_WIDTH = 56;
|
||||
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
const PAIRING_POLL_INTERVAL_MS = 5_000;
|
||||
const PAIRING_DISMISS_SNOOZE_MS = 30_000;
|
||||
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
|
||||
type ShellRoute = {
|
||||
view: ShellView;
|
||||
@@ -82,6 +94,106 @@ type ShellRoute = {
|
||||
settingsSection: SettingsSectionKey;
|
||||
};
|
||||
|
||||
type PairingChannelPresentation = {
|
||||
label: string;
|
||||
initials: string;
|
||||
color: string;
|
||||
logoUrl?: string;
|
||||
};
|
||||
|
||||
const PAIRING_CHANNEL_PRESENTATION: Record<string, PairingChannelPresentation> = {
|
||||
dingtalk: {
|
||||
label: "DingTalk",
|
||||
initials: "DT",
|
||||
color: "#FF6A00",
|
||||
logoUrl: "https://www.dingtalk.com/favicon.ico",
|
||||
},
|
||||
discord: {
|
||||
label: "Discord",
|
||||
initials: "DC",
|
||||
color: "#5865F2",
|
||||
logoUrl: "https://discord.com/favicon.ico",
|
||||
},
|
||||
email: {
|
||||
label: "Email",
|
||||
initials: "EM",
|
||||
color: "#EA4335",
|
||||
logoUrl: "https://gmail.com/favicon.ico",
|
||||
},
|
||||
feishu: {
|
||||
label: "Feishu",
|
||||
initials: "FS",
|
||||
color: "#3370FF",
|
||||
logoUrl: "https://www.feishu.cn/favicon.ico",
|
||||
},
|
||||
lark: {
|
||||
label: "Lark",
|
||||
initials: "LK",
|
||||
color: "#3370FF",
|
||||
logoUrl: "https://www.larksuite.com/favicon.ico",
|
||||
},
|
||||
matrix: {
|
||||
label: "Matrix",
|
||||
initials: "M",
|
||||
color: "#111827",
|
||||
logoUrl: "https://matrix.org/favicon.ico",
|
||||
},
|
||||
msteams: {
|
||||
label: "Microsoft Teams",
|
||||
initials: "MT",
|
||||
color: "#6264A7",
|
||||
logoUrl: "https://www.microsoft.com/favicon.ico",
|
||||
},
|
||||
napcat: {
|
||||
label: "NapCat",
|
||||
initials: "NC",
|
||||
color: "#7C3AED",
|
||||
logoUrl: "https://napneko.github.io/favicon.ico",
|
||||
},
|
||||
qq: {
|
||||
label: "QQ",
|
||||
initials: "QQ",
|
||||
color: "#12B7F5",
|
||||
logoUrl: "https://im.qq.com/favicon.ico",
|
||||
},
|
||||
signal: {
|
||||
label: "Signal",
|
||||
initials: "SG",
|
||||
color: "#3A76F0",
|
||||
logoUrl: "https://signal.org/favicon.ico",
|
||||
},
|
||||
slack: {
|
||||
label: "Slack",
|
||||
initials: "SL",
|
||||
color: "#611F69",
|
||||
logoUrl: "https://slack.com/favicon.ico",
|
||||
},
|
||||
telegram: {
|
||||
label: "Telegram",
|
||||
initials: "TG",
|
||||
color: "#229ED9",
|
||||
logoUrl: "https://telegram.org/favicon.ico",
|
||||
},
|
||||
wecom: {
|
||||
label: "WeCom",
|
||||
initials: "WC",
|
||||
color: "#2F7DFF",
|
||||
logoUrl: "https://work.weixin.qq.com/favicon.ico",
|
||||
},
|
||||
weixin: {
|
||||
label: "WeChat",
|
||||
initials: "WX",
|
||||
color: "#07C160",
|
||||
logoUrl: "https://weixin.qq.com/favicon.ico",
|
||||
},
|
||||
whatsapp: {
|
||||
label: "WhatsApp",
|
||||
initials: "WA",
|
||||
color: "#25D366",
|
||||
logoUrl: "https://www.whatsapp.com/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
||||
"overview",
|
||||
"appearance",
|
||||
@@ -89,6 +201,7 @@ const SETTINGS_SECTION_KEYS: SettingsSectionKey[] = [
|
||||
"image",
|
||||
"voice",
|
||||
"browser",
|
||||
"channels",
|
||||
"apps",
|
||||
"automations",
|
||||
"skills",
|
||||
@@ -109,11 +222,47 @@ function shellViewForSettingsSection(section: SettingsSectionKey): ShellView {
|
||||
return "settings";
|
||||
}
|
||||
|
||||
function fallbackRestartHash(hash: string): boolean {
|
||||
return !hash || hash === "/" || hash === "/new";
|
||||
}
|
||||
|
||||
function rememberRestartRoute(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(RESTART_ROUTE_KEY, window.location.hash || "#/new");
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRestoreRestartHash(hash: string): string {
|
||||
if (typeof window === "undefined" || !fallbackRestartHash(hash)) return hash;
|
||||
try {
|
||||
const startedAt = Number(window.localStorage.getItem(RESTART_STARTED_KEY) ?? "0");
|
||||
const storedHash = window.localStorage.getItem(RESTART_ROUTE_KEY);
|
||||
if (!startedAt || !storedHash || Date.now() - startedAt > RESTART_ROUTE_TTL_MS) {
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
return hash;
|
||||
}
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
const nextHash = storedHash.startsWith("#") ? storedHash : `#${storedHash}`;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}${nextHash}`,
|
||||
);
|
||||
return nextHash.slice(1);
|
||||
} catch {
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
function readShellRoute(): ShellRoute {
|
||||
if (typeof window === "undefined") return defaultShellRoute();
|
||||
const hash = window.location.hash.startsWith("#")
|
||||
const currentHash = window.location.hash.startsWith("#")
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
const hash = maybeRestoreRestartHash(currentHash);
|
||||
if (!hash || hash === "/" || hash === "/new") return defaultShellRoute();
|
||||
|
||||
const [path, query = ""] = hash.split("?", 2);
|
||||
@@ -346,6 +495,286 @@ function HostChrome({
|
||||
);
|
||||
}
|
||||
|
||||
function PairingCodePopup({
|
||||
requests,
|
||||
total,
|
||||
busyCode,
|
||||
error,
|
||||
onApprove,
|
||||
onDismiss,
|
||||
}: {
|
||||
requests: PairingRequestInfo[];
|
||||
total: number;
|
||||
busyCode: string | null;
|
||||
error: string | null;
|
||||
onApprove: (code: string) => void;
|
||||
onDismiss: (code: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const normalizedCode = normalizePairingCode(value);
|
||||
const matchedRequest = useMemo(
|
||||
() => requests.find((request) => request.code === normalizedCode) ?? null,
|
||||
[normalizedCode, requests],
|
||||
);
|
||||
const firstRequest = requests[0] ?? null;
|
||||
const displayRequest = matchedRequest ?? firstRequest;
|
||||
const expires = formatPairingExpiry(firstRequest?.expires_in_seconds);
|
||||
const isCompleteCode = normalizedCode.length === 9;
|
||||
const showNoMatch = isCompleteCode && !matchedRequest && !busyCode;
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchedRequest || busyCode) return;
|
||||
onApprove(matchedRequest.code);
|
||||
}, [busyCode, matchedRequest, onApprove]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requests.length) setValue("");
|
||||
}, [requests.length]);
|
||||
|
||||
if (!firstRequest) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
aria-label={t("app.pairing.title", { defaultValue: "Pair a chat user" })}
|
||||
className={cn(
|
||||
"fixed right-4 top-[calc(0.75rem+env(safe-area-inset-top))] z-[70]",
|
||||
"w-[min(calc(100vw-2rem),24rem)] rounded-[24px]",
|
||||
"border border-border/70 bg-popover/95 p-4 text-popover-foreground",
|
||||
"shadow-[0_24px_70px_rgba(15,23,42,0.20)] backdrop-blur-xl",
|
||||
"animate-in fade-in-0 slide-in-from-top-2 duration-200",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<PairingChannelBadge channel={displayRequest.channel} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[15px] font-semibold tracking-[-0.01em]">
|
||||
{t("app.pairing.title", { defaultValue: "Pair a chat user" })}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
||||
{t("app.pairing.description", {
|
||||
defaultValue: "Enter the pairing code shown in the chat.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.close", { defaultValue: "Close" })}
|
||||
onClick={() => onDismiss(firstRequest.code)}
|
||||
className="rounded-full p-1 text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="mt-4 block text-[12.5px] font-medium text-foreground">
|
||||
{t("app.pairing.code", { defaultValue: "Pairing code" })}
|
||||
</label>
|
||||
<PairingCodeSlots
|
||||
value={value}
|
||||
disabled={Boolean(busyCode)}
|
||||
matched={Boolean(matchedRequest)}
|
||||
invalid={showNoMatch}
|
||||
ariaLabel={t("app.pairing.code", { defaultValue: "Pairing code" })}
|
||||
onChange={(next) => setValue(formatPairingCodeInput(next))}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-[12.5px] text-muted-foreground">
|
||||
<span>
|
||||
{matchedRequest
|
||||
? t("app.pairing.matched", {
|
||||
defaultValue: "Matched {{channel}}. Connecting...",
|
||||
channel: channelLabel(matchedRequest.channel),
|
||||
})
|
||||
: t("app.pairing.expiresInline", {
|
||||
defaultValue: "Code expires {{expires}}.",
|
||||
expires,
|
||||
})}
|
||||
</span>
|
||||
{total > 1 ? (
|
||||
<span className="shrink-0">
|
||||
{t("app.pairing.queueCount", {
|
||||
defaultValue: "{{count}} pending",
|
||||
count: total,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showNoMatch ? (
|
||||
<p className="mt-2 text-[12px] leading-5 text-destructive">
|
||||
{t("app.pairing.noMatch", {
|
||||
defaultValue: "No pending request matches this code.",
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="mt-2 text-[12px] leading-5 text-destructive">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingChannelBadge({ channel }: { channel: string }) {
|
||||
const key = pairingChannelKey(channel);
|
||||
const presentation = PAIRING_CHANNEL_PRESENTATION[key];
|
||||
const label = presentation?.label ?? channelLabel(channel);
|
||||
const initials = presentation?.initials ?? label.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#10B981";
|
||||
const logoUrls = useMemo(
|
||||
() => logoFallbackUrls(presentation?.logoUrl),
|
||||
[presentation?.logoUrl],
|
||||
);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mt-0.5 grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-2xl border bg-background shadow-sm"
|
||||
style={{
|
||||
borderColor: `${color}30`,
|
||||
boxShadow: `inset 0 0 0 1px ${color}14, 0 1px 2px rgba(15,23,42,0.06)`,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-6 w-6 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : presentation ? (
|
||||
<span className="text-[11px] font-bold tracking-[-0.02em]" style={{ color }}>
|
||||
{initials}
|
||||
</span>
|
||||
) : (
|
||||
<ShieldCheck className="h-5 w-5" style={{ color }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PairingCodeSlots({
|
||||
value,
|
||||
disabled,
|
||||
matched,
|
||||
invalid,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
matched: boolean;
|
||||
invalid: boolean;
|
||||
ariaLabel: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const compact = compactPairingCode(value);
|
||||
const activeIndex = Math.min(compact.length, 7);
|
||||
const slots = Array.from({ length: 8 }, (_, index) => compact[index] ?? "");
|
||||
const renderSlot = (char: string, index: number) => {
|
||||
const highlighted = focused && index === activeIndex && !matched && !invalid;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"grid h-10 w-7 place-items-center rounded-xl border",
|
||||
"bg-background/80 font-mono text-[16px] font-semibold uppercase",
|
||||
"text-foreground shadow-[0_1px_1px_rgba(15,23,42,0.04)] transition",
|
||||
matched
|
||||
? "border-emerald-500/45 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: invalid
|
||||
? "border-destructive/55 bg-destructive/5 text-destructive"
|
||||
: highlighted
|
||||
? "border-foreground/30 bg-background text-foreground"
|
||||
: char
|
||||
? "border-border/80 bg-background text-foreground"
|
||||
: "border-border/55 bg-muted/35 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{char || " "}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mt-2 rounded-2xl border border-transparent p-1",
|
||||
"transition duration-150",
|
||||
focused && !disabled ? "border-ring/20 bg-muted/35" : "bg-transparent",
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
aria-label={ariaLabel}
|
||||
inputMode="text"
|
||||
autoCapitalize="characters"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
maxLength={9}
|
||||
disabled={disabled}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="absolute inset-0 z-10 h-full w-full cursor-text opacity-0 disabled:cursor-default"
|
||||
/>
|
||||
<div className="pointer-events-none flex items-center gap-1.5">
|
||||
{slots.slice(0, 4).map((char, index) => renderSlot(char, index))}
|
||||
<div className="mx-0.5 h-px w-2.5 rounded-full bg-muted-foreground/35" />
|
||||
{slots.slice(4).map((char, index) => renderSlot(char, index + 4))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function compactPairingCode(raw: string): string {
|
||||
return raw.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
function formatPairingCodeInput(raw: string): string {
|
||||
const compact = compactPairingCode(raw);
|
||||
if (compact.length <= 4) return compact;
|
||||
return `${compact.slice(0, 4)}-${compact.slice(4)}`;
|
||||
}
|
||||
|
||||
function normalizePairingCode(raw: string): string {
|
||||
return formatPairingCodeInput(raw);
|
||||
}
|
||||
|
||||
function pairingChannelKey(channel: string): string {
|
||||
const raw = channel.trim().toLowerCase();
|
||||
if (!raw) return "";
|
||||
return raw.split(/[.:]/)[0] ?? raw;
|
||||
}
|
||||
|
||||
function channelLabel(channel: string): string {
|
||||
const key = pairingChannelKey(channel);
|
||||
return PAIRING_CHANNEL_PRESENTATION[key]?.label ?? channel;
|
||||
}
|
||||
|
||||
function formatPairingExpiry(seconds: number | null | undefined): string {
|
||||
if (seconds == null) return "soon";
|
||||
if (seconds <= 0) return "expired";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
return `${Math.ceil(seconds / 60)} min`;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<BootState>({ status: "loading" });
|
||||
@@ -510,9 +939,24 @@ export default function App() {
|
||||
if (!hostApi?.restartEngine) {
|
||||
throw new Error("native engine restart is unavailable");
|
||||
}
|
||||
await hostApi.restartEngine();
|
||||
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
|
||||
return refreshed.token;
|
||||
rememberRestartRoute();
|
||||
try {
|
||||
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
try {
|
||||
await hostApi.restartEngine();
|
||||
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
|
||||
return refreshed.token;
|
||||
} finally {
|
||||
try {
|
||||
window.localStorage.removeItem(RESTART_STARTED_KEY);
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -585,6 +1029,12 @@ function Shell({
|
||||
const restartSawDisconnectRef = useRef(false);
|
||||
const [restartToast, setRestartToast] = useState<string | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
|
||||
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
|
||||
const [pairingError, setPairingError] = useState<string | null>(null);
|
||||
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
|
||||
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
|
||||
@@ -657,6 +1107,36 @@ function Shell({
|
||||
writeSessionUpdateChatIds(updatedChatIds);
|
||||
}, [updatedChatIds]);
|
||||
|
||||
const refreshPairingRequests = useCallback(async () => {
|
||||
try {
|
||||
const payload = await fetchPairingRequests(token);
|
||||
const requests = Array.isArray(payload.requests) ? payload.requests : [];
|
||||
setPairingRequests(requests);
|
||||
setSnoozedPairingCodes((current) => {
|
||||
if (current.size === 0) return current;
|
||||
const activeCodes = new Set(requests.map((request) => request.code));
|
||||
const now = Date.now();
|
||||
const next = new Map(
|
||||
Array.from(current).filter(
|
||||
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
|
||||
),
|
||||
);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
} catch {
|
||||
// Pairing is an opportunistic WebUI affordance. The slash command path
|
||||
// remains available if this polling request fails.
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPairingRequests();
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshPairingRequests();
|
||||
}, PAIRING_POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshPairingRequests]);
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
@@ -1237,6 +1717,7 @@ function Shell({
|
||||
if (!chatId) return;
|
||||
restartSawDisconnectRef.current = false;
|
||||
setIsRestarting(true);
|
||||
rememberRestartRoute();
|
||||
try {
|
||||
window.localStorage.setItem(RESTART_STARTED_KEY, String(Date.now()));
|
||||
} catch {
|
||||
@@ -1302,6 +1783,7 @@ function Shell({
|
||||
if (!restartSawDisconnectRef.current && elapsedMs < 1500) return;
|
||||
try {
|
||||
window.localStorage.removeItem(RESTART_STARTED_KEY);
|
||||
window.localStorage.removeItem(RESTART_ROUTE_KEY);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
@@ -1357,6 +1839,50 @@ function Shell({
|
||||
setPendingDelete({ key, label, automations });
|
||||
}, [getSessionAutomations]);
|
||||
|
||||
const visiblePairingRequests = useMemo(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
return pairingRequests.filter((request) => {
|
||||
const snoozedUntil = snoozedPairingCodes.get(request.code);
|
||||
return !snoozedUntil || snoozedUntil <= now;
|
||||
});
|
||||
},
|
||||
[pairingRequests, snoozedPairingCodes],
|
||||
);
|
||||
|
||||
const onPairingAction = useCallback(
|
||||
async (action: "approve" | "deny", code: string) => {
|
||||
setPairingBusyCode(code);
|
||||
setPairingError(null);
|
||||
try {
|
||||
const payload = await runPairingAction(token, action, code);
|
||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
setSnoozedPairingCodes((current) => {
|
||||
if (!current.has(code)) return current;
|
||||
const next = new Map(current);
|
||||
next.delete(code);
|
||||
return next;
|
||||
});
|
||||
} catch (e) {
|
||||
setPairingError((e as Error).message);
|
||||
void refreshPairingRequests();
|
||||
} finally {
|
||||
setPairingBusyCode(null);
|
||||
}
|
||||
},
|
||||
[refreshPairingRequests, token],
|
||||
);
|
||||
|
||||
const onDismissPairingRequest = useCallback((code: string) => {
|
||||
setSnoozedPairingCodes((current) => {
|
||||
const snoozedUntil = Date.now() + PAIRING_DISMISS_SNOOZE_MS;
|
||||
if (current.get(code) === snoozedUntil) return current;
|
||||
const next = new Map(current);
|
||||
next.set(code, snoozedUntil);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerTitle = activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
@@ -1653,6 +2179,14 @@ function Shell({
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
<PairingCodePopup
|
||||
requests={visiblePairingRequests}
|
||||
total={visiblePairingRequests.length}
|
||||
busyCode={pairingBusyCode}
|
||||
error={pairingError}
|
||||
onApprove={(code) => void onPairingAction("approve", code)}
|
||||
onDismiss={onDismissPairingRequest}
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type { CliAppInfo, McpPresetInfo } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -137,16 +138,13 @@ export function CliAppMentionToken({
|
||||
variant: "composer" | "message";
|
||||
isHero?: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const color = app.brand_color || "hsl(var(--primary))";
|
||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [app.logo_url]);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
|
||||
@@ -175,7 +173,10 @@ export function CliAppMentionToken({
|
||||
src={logoUrl ?? ""}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
@@ -196,16 +197,13 @@ export function McpPresetMentionToken({
|
||||
variant: "composer" | "message";
|
||||
isHero?: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const color = preset.brand_color || "hsl(var(--primary))";
|
||||
const mentionName = label.startsWith("@") ? label.slice(1) : label;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
const testIdPrefix = variant === "composer" ? "composer" : "message";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [preset.logo_url]);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid={`${testIdPrefix}-mcp-mention-${preset.name}`}
|
||||
@@ -234,7 +232,10 @@ export function McpPresetMentionToken({
|
||||
src={logoUrl ?? ""}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
Children,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
|
||||
@@ -22,6 +19,7 @@ import {
|
||||
isFilePatternReference,
|
||||
isLikelyFilePath,
|
||||
} from "@/components/FileReferenceChip";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { inferMediaKind } from "@/lib/media";
|
||||
import { faviconUrls } from "@/lib/provider-brand";
|
||||
import { remarkTexMath } from "@/lib/remark-tex-math";
|
||||
@@ -304,7 +302,7 @@ function inlineLinkPreviewFromChildren(children: ReactNode): InlineLinkPreview |
|
||||
}
|
||||
|
||||
function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
const { favicon, onFaviconError } = useFaviconFallback(link.host);
|
||||
const { favicon, onFaviconError, onFaviconLoad } = useFaviconFallback(link.host);
|
||||
const label = link.prefix
|
||||
? `${link.prefix} — ${link.title}`
|
||||
: link.title;
|
||||
@@ -332,7 +330,9 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
src={favicon}
|
||||
alt=""
|
||||
className="h-3 w-3 rounded-[2px] object-contain"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onFaviconLoad}
|
||||
onError={onFaviconError}
|
||||
/>
|
||||
) : (
|
||||
@@ -348,19 +348,12 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
|
||||
function useFaviconFallback(host: string) {
|
||||
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
|
||||
const [faviconIndex, setFaviconIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFaviconIndex(0);
|
||||
}, [host]);
|
||||
|
||||
const onFaviconError = useCallback(() => {
|
||||
setFaviconIndex((index) => Math.min(index + 1, faviconCandidates.length));
|
||||
}, [faviconCandidates.length]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(faviconCandidates);
|
||||
|
||||
return {
|
||||
favicon: faviconCandidates[faviconIndex] ?? null,
|
||||
onFaviconError,
|
||||
favicon: logoUrl ?? null,
|
||||
onFaviconError: onLogoError,
|
||||
onFaviconLoad: onLogoLoad,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ToggleButton({
|
||||
checked,
|
||||
disabled,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel ?? label}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled) onChange(!checked);
|
||||
}}
|
||||
className={cn(
|
||||
"relative inline-flex h-[22px] w-[38px] shrink-0 items-center rounded-full p-[2px]",
|
||||
"transition-colors duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
checked
|
||||
? "bg-[#2997FF] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)]"
|
||||
: "bg-muted shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] hover:bg-muted/80",
|
||||
disabled && "cursor-default opacity-60",
|
||||
disabled && checked && "hover:bg-[#2997FF]",
|
||||
disabled && !checked && "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-[18px] w-[18px] rounded-full bg-background shadow-[0_1px_2px_rgba(0,0,0,0.18),0_2px_7px_rgba(0,0,0,0.11)]",
|
||||
"transition-transform duration-200 ease-out",
|
||||
checked ? "translate-x-[16px]" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
CHANNEL_PRESENTATION,
|
||||
type ChannelSetupPresentation,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type { NanobotFeatureInfo } from "@/lib/types";
|
||||
|
||||
export type ChannelFilter = "all" | "on" | "off";
|
||||
|
||||
export function channelSetup(feature: NanobotFeatureInfo): ChannelSetupPresentation {
|
||||
return CHANNEL_PRESENTATION[feature.name]?.setup ?? {
|
||||
summary:
|
||||
"Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.",
|
||||
steps: [
|
||||
`Open ~/.nanobot/config.json and find channels.${feature.name}.`,
|
||||
"Add the credentials required by that platform, using the channel documentation as the source of truth.",
|
||||
"Restart nanobot, then send a small test message from that platform.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function ChannelLogo({
|
||||
feature,
|
||||
showBrandLogos,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
showBrandLogos: boolean;
|
||||
}) {
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#6B7280";
|
||||
const Icon = presentation?.icon;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
|
||||
if (showBrandLogos && logoUrl) {
|
||||
return (
|
||||
<span
|
||||
className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] border border-border/45 bg-background"
|
||||
style={{ boxShadow: `inset 0 0 0 1px ${color}22` }}
|
||||
>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-5.5 w-5.5 max-h-6 max-w-6 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (Icon) {
|
||||
return (
|
||||
<span
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background"
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon className="h-5 w-5" strokeWidth={2.25} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] border border-border/45 bg-background text-[11px] font-bold"
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
|
||||
aria-hidden
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function channelDisplayName(feature: NanobotFeatureInfo): string {
|
||||
return CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name;
|
||||
}
|
||||
|
||||
export function channelDescription(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||
const fallback =
|
||||
CHANNEL_PRESENTATION[feature.name]?.description ??
|
||||
`Use nanobot from ${channelDisplayName(feature)}.`;
|
||||
return t(`settings.channels.items.${feature.name}.description`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
export function channelRequirements(feature: NanobotFeatureInfo, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||
const fallback =
|
||||
CHANNEL_PRESENTATION[feature.name]?.requirements ??
|
||||
"Channel credentials and gateway settings";
|
||||
return t(`settings.channels.items.${feature.name}.requirements`, { defaultValue: fallback });
|
||||
}
|
||||
|
||||
export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean {
|
||||
if (filter === "on") return feature.enabled;
|
||||
if (filter === "off") return !feature.enabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function channelStatusLabel(
|
||||
feature: NanobotFeatureInfo,
|
||||
tx: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
if (feature.enabled) return tx("settings.values.on", "On");
|
||||
return tx("settings.values.off", "Off");
|
||||
}
|
||||
|
||||
export function channelSearchText(feature: NanobotFeatureInfo): string {
|
||||
return [
|
||||
channelDisplayName(feature),
|
||||
feature.display_name,
|
||||
feature.name,
|
||||
feature.status,
|
||||
CHANNEL_PRESENTATION[feature.name]?.description,
|
||||
CHANNEL_PRESENTATION[feature.name]?.requirements,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
export function ChannelStatusBadge({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="shrink-0 rounded-full bg-muted/75 px-2 py-0.5 text-[11px] font-medium leading-4 text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Check, Loader2, Network, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
cancelChannelConnect,
|
||||
pollChannelConnect,
|
||||
startChannelConnect,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
ChannelConnectPayload,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
export type ChannelQrConnectLabels = {
|
||||
qrAlt: string;
|
||||
scanTitle: string;
|
||||
scanDescription: string;
|
||||
waiting: string;
|
||||
connected: string;
|
||||
stopped: string;
|
||||
connecting: string;
|
||||
scanAgain: string;
|
||||
connect: string;
|
||||
};
|
||||
|
||||
export function ChannelQrConnectFlow({
|
||||
token,
|
||||
channelName,
|
||||
startOptions = {},
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
labels,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
channelName: "feishu" | "weixin";
|
||||
startOptions?: {
|
||||
domain?: "feishu" | "lark";
|
||||
instanceId?: string;
|
||||
mode?: "replace" | "create";
|
||||
force?: boolean;
|
||||
};
|
||||
idleLabel?: string;
|
||||
connectRequestId?: number;
|
||||
labels: ChannelQrConnectLabels;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [connect, setConnect] = useState<ChannelConnectPayload | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||
const pollInFlight = useRef(false);
|
||||
const startDomain = startOptions.domain;
|
||||
const startInstanceId = startOptions.instanceId;
|
||||
const startMode = startOptions.mode;
|
||||
const startForce = startOptions.force;
|
||||
|
||||
const pending = connect?.status === "pending";
|
||||
const succeeded = connect?.status === "succeeded";
|
||||
const canStart = !pending && !busy;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connect?.qr_url) {
|
||||
setQrDataUrl("");
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void QRCode.toDataURL(connect.qr_url, {
|
||||
width: 184,
|
||||
margin: 1,
|
||||
color: { dark: "#111827", light: "#ffffff" },
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setQrDataUrl("");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connect?.qr_url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connect?.session_id || connect.status !== "pending") return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
if (pollInFlight.current) return;
|
||||
pollInFlight.current = true;
|
||||
try {
|
||||
const payload = await pollChannelConnect(token, channelName, connect.session_id);
|
||||
if (cancelled) return;
|
||||
setConnect((current) => ({
|
||||
...(current ?? payload),
|
||||
...payload,
|
||||
qr_url: payload.qr_url ?? current?.qr_url,
|
||||
}));
|
||||
if (payload.nanobot_features) {
|
||||
onFeaturesUpdate(payload.nanobot_features);
|
||||
}
|
||||
if (payload.status !== "pending") {
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
} finally {
|
||||
pollInFlight.current = false;
|
||||
}
|
||||
};
|
||||
const initial = window.setTimeout(() => void poll(), 900);
|
||||
const interval = window.setInterval(
|
||||
() => void poll(),
|
||||
Math.max(2500, connect.interval_ms ?? 5000),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(initial);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, token]);
|
||||
|
||||
const start = useCallback(async (force = false) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await startChannelConnect(token, channelName, {
|
||||
domain: startDomain,
|
||||
instanceId: startInstanceId,
|
||||
mode: startMode,
|
||||
force: force || startForce,
|
||||
});
|
||||
setConnect(payload);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [channelName, startDomain, startForce, startInstanceId, startMode, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||
setHandledRequestId(connectRequestId);
|
||||
void start();
|
||||
}, [connectRequestId, handledRequestId, start]);
|
||||
|
||||
const cancel = async () => {
|
||||
if (!connect?.session_id) {
|
||||
setConnect(null);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await cancelChannelConnect(token, channelName, connect.session_id);
|
||||
setConnect(payload);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{pending ? (
|
||||
<div className="grid gap-4 rounded-[14px] border border-border/70 p-4 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||
<div className="grid h-[196px] w-[196px] place-items-center rounded-[14px] border border-border/60 bg-background">
|
||||
{qrDataUrl ? (
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={labels.qrAlt}
|
||||
className="h-[184px] w-[184px]"
|
||||
/>
|
||||
) : (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col justify-center">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{labels.scanTitle}
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
|
||||
{labels.scanDescription}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-[12px] text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
{labels.waiting}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
onClick={() => void cancel()}
|
||||
disabled={busy}
|
||||
>
|
||||
{tx("settings.actions.cancel", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{succeeded ? (
|
||||
<div className="flex items-center gap-2 rounded-[12px] border border-emerald-500/20 px-3 py-2 text-[12px] font-medium text-emerald-700 dark:text-emerald-200">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
{connect.message ?? labels.connected}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{connect && ["expired", "failed", "cancelled"].includes(connect.status) ? (
|
||||
<div className="rounded-[12px] border border-border/60 px-3 py-2 text-[12px] leading-5 text-muted-foreground">
|
||||
{connect.message || labels.stopped}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void start(channelName === "weixin" && succeeded)}
|
||||
disabled={!canStart}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : succeeded ? (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Network className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{pending
|
||||
? labels.connecting
|
||||
: succeeded
|
||||
? labels.scanAgain
|
||||
: idleLabel ?? labels.connect}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function FeishuConnectFlow({
|
||||
token,
|
||||
instanceId = "default",
|
||||
mode = "replace",
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
instanceId?: string;
|
||||
mode?: "replace" | "create";
|
||||
idleLabel?: string;
|
||||
connectRequestId?: number;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<ChannelQrConnectFlow
|
||||
token={token}
|
||||
channelName="feishu"
|
||||
startOptions={{ domain: "feishu", instanceId, mode }}
|
||||
idleLabel={idleLabel}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
labels={{
|
||||
qrAlt: tx("settings.channels.feishuQrAlt", "Feishu connection QR code"),
|
||||
scanTitle: tx("settings.channels.feishuScanTitle", "Scan with Feishu"),
|
||||
scanDescription: tx(
|
||||
"settings.channels.feishuScanDescription",
|
||||
"Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
|
||||
),
|
||||
waiting: tx("settings.channels.feishuWaiting", "Waiting for authorization..."),
|
||||
connected: tx("settings.channels.feishuConnected", "Feishu is connected."),
|
||||
stopped: tx("settings.channels.feishuConnectStopped", "Connection stopped."),
|
||||
connecting: tx("settings.channels.feishuConnecting", "Connecting..."),
|
||||
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
|
||||
connect: tx("settings.channels.connect", "Connect"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeixinConnectFlow({
|
||||
token,
|
||||
idleLabel,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
idleLabel?: string;
|
||||
connectRequestId?: number;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<ChannelQrConnectFlow
|
||||
token={token}
|
||||
channelName="weixin"
|
||||
idleLabel={idleLabel}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
labels={{
|
||||
qrAlt: tx("settings.channels.weixinQrAlt", "WeChat login QR code"),
|
||||
scanTitle: tx("settings.channels.weixinScanTitle", "Scan with WeChat"),
|
||||
scanDescription: tx(
|
||||
"settings.channels.weixinScanDescription",
|
||||
"Use WeChat on your phone to scan this code. nanobot saves the account state locally after login.",
|
||||
),
|
||||
waiting: tx("settings.channels.weixinWaiting", "Waiting for WeChat scan..."),
|
||||
connected: tx("settings.channels.weixinConnected", "WeChat is connected."),
|
||||
stopped: tx("settings.channels.weixinConnectStopped", "WeChat login stopped."),
|
||||
connecting: tx("settings.channels.weixinConnecting", "Connecting..."),
|
||||
scanAgain: tx("settings.channels.scanAgain", "Scan again"),
|
||||
connect: tx("settings.channels.connect", "Connect"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import {
|
||||
type ChannelProviderPreset,
|
||||
type ChannelSetupPresentation,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import {
|
||||
CredentialForm,
|
||||
channelValuesForSubmit,
|
||||
defaultChannelFieldValues,
|
||||
} from "@/components/settings/channels/CredentialForm";
|
||||
import {
|
||||
ChannelLogo,
|
||||
ChannelStatusBadge,
|
||||
channelDescription,
|
||||
channelDisplayName,
|
||||
channelRequirements,
|
||||
channelSetup,
|
||||
channelStatusLabel,
|
||||
} from "@/components/settings/channels/ChannelIdentity";
|
||||
import {
|
||||
FeishuConnectFlow,
|
||||
WeixinConnectFlow,
|
||||
} from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
import {
|
||||
ChannelProviderPresets,
|
||||
ChannelSetupActions,
|
||||
ChannelSetupLinks,
|
||||
ChannelSetupSteps,
|
||||
ChannelValidationBadge,
|
||||
ChannelValidationChecks,
|
||||
ChannelValidationDetails,
|
||||
} from "@/components/settings/channels/ChannelSetupParts";
|
||||
import { FeishuAssistantsPanel } from "@/components/settings/channels/FeishuAssistantsPanel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
configureChannel,
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import type {
|
||||
ChannelValidationPayload,
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ChannelCatalogRow({
|
||||
feature,
|
||||
selected,
|
||||
showBrandLogos,
|
||||
onSelect,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
selected: boolean;
|
||||
showBrandLogos: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("settings.channels.selectChannel", {
|
||||
name: channelDisplayName(feature),
|
||||
defaultValue: "View {{name}} settings",
|
||||
})}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group flex w-full min-w-0 items-center gap-3 rounded-[14px] border px-3 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border/80",
|
||||
selected
|
||||
? "border-border/55 bg-muted/35"
|
||||
: "border-transparent hover:border-border/45 hover:bg-muted/25",
|
||||
)}
|
||||
>
|
||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
||||
{channelDisplayName(feature)}
|
||||
</h3>
|
||||
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">
|
||||
{channelDescription(feature, t)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
selected && "translate-x-0.5 text-foreground",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupPanel({
|
||||
token,
|
||||
feature,
|
||||
actionKey,
|
||||
chatAppsDocsUrl,
|
||||
showBrandLogos,
|
||||
onAction,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
actionKey: string | null;
|
||||
chatAppsDocsUrl?: string;
|
||||
showBrandLogos: boolean;
|
||||
onAction: (action: "enable" | "disable", name: string) => void;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [connectRequestId, setConnectRequestId] = useState(0);
|
||||
if (feature.name === "feishu") {
|
||||
return (
|
||||
<FeishuAssistantsPanel
|
||||
token={token}
|
||||
feature={feature}
|
||||
showBrandLogos={showBrandLogos}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const enableBusy = actionKey === `enable:${feature.name}`;
|
||||
const disableBusy = actionKey === `disable:${feature.name}`;
|
||||
const missingSupport = feature.enabled && !feature.installed;
|
||||
const requiredWebui = feature.name === "websocket";
|
||||
const channelChecked = requiredWebui || feature.enabled;
|
||||
const channelBusy = enableBusy || disableBusy;
|
||||
const setup = channelSetup(feature);
|
||||
const needsSetupBeforeEnable =
|
||||
!channelChecked
|
||||
&& feature.configured === false
|
||||
&& !(feature.name === "weixin" && setup.mode === "connect");
|
||||
const channelToggleDisabled =
|
||||
requiredWebui
|
||||
|| channelBusy
|
||||
|| needsSetupBeforeEnable
|
||||
|| (!feature.install_supported && !feature.installed && !feature.enabled);
|
||||
const installSupportLabel = tx("settings.nanobotFeatures.installSupport", "Install support");
|
||||
const toggleAriaLabel = t("settings.channels.toggleChannel", {
|
||||
name: channelDisplayName(feature),
|
||||
defaultValue: "{{name}} channel",
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
|
||||
{channelDisplayName(feature)}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
||||
{channelDescription(feature, t)}
|
||||
</p>
|
||||
{missingSupport && feature.install_supported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={enableBusy}
|
||||
onClick={() => onAction("enable", feature.name)}
|
||||
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
>
|
||||
{enableBusy ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{installSupportLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 pt-1">
|
||||
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
|
||||
{channelBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : null}
|
||||
<ToggleButton
|
||||
checked={channelChecked}
|
||||
disabled={channelToggleDisabled}
|
||||
ariaLabel={toggleAriaLabel}
|
||||
label={channelChecked ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
onChange={(checked) => {
|
||||
if (
|
||||
feature.name === "weixin"
|
||||
&& checked
|
||||
&& !channelChecked
|
||||
&& feature.configured === false
|
||||
) {
|
||||
setConnectRequestId((current) => current + 1);
|
||||
return;
|
||||
}
|
||||
onAction(checked ? "enable" : "disable", feature.name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChannelSetupSurface
|
||||
token={token}
|
||||
feature={feature}
|
||||
setup={setup}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelSetupSurface({
|
||||
token,
|
||||
feature,
|
||||
setup,
|
||||
chatAppsDocsUrl,
|
||||
connectRequestId,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
chatAppsDocsUrl?: string;
|
||||
connectRequestId: number;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validation, setValidation] = useState<ChannelValidationPayload | null>(null);
|
||||
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
|
||||
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
|
||||
const configValuesKey = JSON.stringify(feature.config_values ?? {});
|
||||
const configuredFields = useMemo(
|
||||
() => new Set(feature.configured_fields ?? []),
|
||||
[feature.configured_fields],
|
||||
);
|
||||
const mode = setup.mode ?? "credentials";
|
||||
const fields = setup.fields ?? [];
|
||||
const requiredFields = fields.filter((field) => !field.optional);
|
||||
const primaryFields = requiredFields.length ? requiredFields : fields.slice(0, 1);
|
||||
const optionalFields = fields.filter((field) => field.optional);
|
||||
const manualFields = setup.manualFields ?? [];
|
||||
const advancedFields = mode === "connect" ? manualFields : optionalFields;
|
||||
const editableFields = mode === "credentials" ? fields : mode === "connect" ? manualFields : [];
|
||||
const hasAdvanced = advancedFields.length > 0;
|
||||
const requirements = channelRequirements(feature, t);
|
||||
const summary = t(`settings.channels.items.${feature.name}.setup.summary`, {
|
||||
defaultValue:
|
||||
setup.summary ??
|
||||
tx(
|
||||
"settings.channels.setupSummary",
|
||||
"Enable only turns on nanobot support. Add the platform credentials, then restart nanobot.",
|
||||
),
|
||||
});
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
|
||||
defaultChannelFieldValues(editableFields, feature.config_values),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setNotice(null);
|
||||
setVisibleSecrets({});
|
||||
setSaving(false);
|
||||
setValidating(false);
|
||||
setValidation(null);
|
||||
setTouchedFields(new Set());
|
||||
setFieldValues(defaultChannelFieldValues(editableFields, feature.config_values));
|
||||
}, [configValuesKey, feature.name]);
|
||||
|
||||
const toggleSecret = (key: string) => {
|
||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
|
||||
};
|
||||
|
||||
const setFieldValue = (key: string, value: string) => {
|
||||
setFieldValues((current) => ({ ...current, [key]: value }));
|
||||
setTouchedFields((current) => new Set(current).add(key));
|
||||
};
|
||||
|
||||
const applyPreset = (preset: ChannelProviderPreset) => {
|
||||
setFieldValues((current) => ({ ...current, ...preset.values }));
|
||||
setTouchedFields((current) => {
|
||||
const next = new Set(current);
|
||||
for (const key of Object.keys(preset.values)) next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const copyCommand = () => {
|
||||
if (!setup.command) return;
|
||||
void copyTextToClipboard(setup.command).then((ok) => {
|
||||
setNotice(
|
||||
ok
|
||||
? tx("settings.channels.commandCopied", "Command copied.")
|
||||
: tx("settings.channels.commandCopyFailed", "Could not copy command."),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const saveCredentialSettings = async () => {
|
||||
setSaving(true);
|
||||
setValidating(true);
|
||||
setNotice(null);
|
||||
const values = channelValuesForSubmit(fields, fieldValues, touchedFields);
|
||||
try {
|
||||
const validationPayload = await validateChannel(token, feature.name, values);
|
||||
setValidation(validationPayload);
|
||||
if (!validationPayload.can_enable) {
|
||||
setNotice(
|
||||
validationPayload.message
|
||||
?? tx("settings.channels.validationFailed", "Check the required setup before enabling."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
feature.name,
|
||||
values,
|
||||
{ enable: true },
|
||||
);
|
||||
if (payload.nanobot_features) {
|
||||
onFeaturesUpdate(payload.nanobot_features);
|
||||
}
|
||||
setNotice(tx("settings.channels.checkedAndEnabled", "Checked and enabled."));
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkCurrentSettings = async () => {
|
||||
setValidating(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await validateChannel(
|
||||
token,
|
||||
feature.name,
|
||||
channelValuesForSubmit(fields, fieldValues, touchedFields),
|
||||
);
|
||||
setValidation(payload);
|
||||
if (payload.message) setNotice(payload.message);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const primaryActionLabel = feature.enabled
|
||||
? tx("settings.channels.checkConnection", "Check connection")
|
||||
: tx("settings.channels.checkAndEnable", "Check and enable");
|
||||
|
||||
return (
|
||||
<div className="mt-5 overflow-hidden rounded-[16px] border border-border/70 bg-background shadow-none">
|
||||
<section className="px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{tx("settings.channels.requiredSetup", "Required setup")}
|
||||
</div>
|
||||
<div className="flex max-w-full flex-wrap justify-end gap-2">
|
||||
{mode !== "webui" ? (
|
||||
<ChannelValidationBadge
|
||||
validation={validation}
|
||||
validating={validating}
|
||||
feature={feature}
|
||||
/>
|
||||
) : null}
|
||||
{mode === "webui" ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-1 text-[11.5px] font-medium text-emerald-700 dark:text-emerald-200">
|
||||
<Check className="h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.channels.managedByWebui", "Managed by WebUI")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">{requirements}</p>
|
||||
|
||||
<p className="mt-3 text-[12.5px] leading-5 text-muted-foreground">{summary}</p>
|
||||
<ChannelValidationDetails validation={validation} />
|
||||
<ChannelSetupLinks feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} />
|
||||
<ChannelSetupActions feature={feature} setup={setup} onNotice={setNotice} />
|
||||
|
||||
{mode === "connect" && feature.name === "feishu" ? (
|
||||
<FeishuConnectFlow
|
||||
token={token}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
) : mode === "connect" && feature.name === "weixin" ? (
|
||||
<WeixinConnectFlow
|
||||
token={token}
|
||||
idleLabel={t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
|
||||
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
|
||||
})}
|
||||
connectRequestId={connectRequestId}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
) : mode === "connect" ? (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() =>
|
||||
setNotice(
|
||||
tx(
|
||||
"settings.channels.connectPreview",
|
||||
"The in-browser connect flow is next. For now, run the command below.",
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(`settings.channels.items.${feature.name}.setup.primaryAction`, {
|
||||
defaultValue: setup.primaryActionLabel ?? tx("settings.channels.connect", "Connect"),
|
||||
})}
|
||||
</Button>
|
||||
{setup.command ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
onClick={copyCommand}
|
||||
>
|
||||
<Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
{tx("settings.channels.copyCommand", "Copy command")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{setup.command ? (
|
||||
<code className="mt-3 block rounded-[10px] border border-border/50 bg-muted/45 px-2.5 py-2 font-mono text-[11px] leading-5 text-foreground">
|
||||
{setup.command}
|
||||
</code>
|
||||
) : null}
|
||||
</>
|
||||
) : mode === "credentials" ? (
|
||||
<>
|
||||
{setup.presets?.length ? (
|
||||
<ChannelProviderPresets
|
||||
featureName={feature.name}
|
||||
presets={setup.presets}
|
||||
onApply={applyPreset}
|
||||
/>
|
||||
) : null}
|
||||
{primaryFields.length ? (
|
||||
<CredentialForm
|
||||
fields={primaryFields}
|
||||
values={fieldValues}
|
||||
configuredFields={configuredFields}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={setFieldValue}
|
||||
onToggleSecret={toggleSecret}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void saveCredentialSettings()}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving || validating ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
{feature.configured || validation ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
||||
onClick={() => void checkCurrentSettings()}
|
||||
disabled={saving || validating}
|
||||
>
|
||||
{tx("settings.channels.checkOnly", "Check only")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{notice ? (
|
||||
<div
|
||||
role="status"
|
||||
className="border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground"
|
||||
>
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{setup.steps.length ? (
|
||||
<ChannelSetupSteps featureName={feature.name} steps={setup.steps} tryIt={setup.tryIt} />
|
||||
) : null}
|
||||
|
||||
{validation?.checks.length ? <ChannelValidationChecks validation={validation} /> : null}
|
||||
|
||||
{hasAdvanced ? (
|
||||
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
|
||||
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{tx("settings.channels.advanced", "Advanced")}
|
||||
<ChevronDown className="h-3.5 w-3.5 transition-transform group-open:rotate-180" aria-hidden />
|
||||
</span>
|
||||
</summary>
|
||||
{advancedFields.length ? (
|
||||
<div className="mt-3">
|
||||
<CredentialForm
|
||||
fields={advancedFields}
|
||||
values={fieldValues}
|
||||
configuredFields={configuredFields}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={setFieldValue}
|
||||
onToggleSecret={toggleSecret}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Clipboard, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CHANNEL_PRESENTATION,
|
||||
docsUrlWithBase,
|
||||
type ChannelProviderPreset,
|
||||
type ChannelSetupPresentation,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import {
|
||||
channelValidationCheckIcon,
|
||||
channelValidationCheckIconClass,
|
||||
channelValidationStatusClass,
|
||||
channelValidationStatusIcon,
|
||||
channelValidationStatusLabel,
|
||||
} from "@/components/settings/channels/CredentialForm";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChannelValidationPayload,
|
||||
NanobotFeatureInfo,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ChannelGuideLink({
|
||||
feature,
|
||||
setup,
|
||||
chatAppsDocsUrl,
|
||||
compact = false,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
chatAppsDocsUrl?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const logoUrls = useMemo(
|
||||
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
|
||||
[presentation?.logoUrl, setup.docsLogoUrl],
|
||||
);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const Icon = presentation?.icon;
|
||||
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#6B7280";
|
||||
const docsUrl = docsUrlWithBase(setup.docsUrl, chatAppsDocsUrl);
|
||||
|
||||
if (!docsUrl) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-2 border border-border/65 bg-background/90 font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45",
|
||||
compact
|
||||
? "shrink-0 rounded-full py-1 pl-1 pr-2.5 text-[11.5px]"
|
||||
: "mt-3 rounded-[12px] py-1.5 pl-1.5 pr-3 text-[12px]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden border border-border/45 bg-background font-bold",
|
||||
compact ? "h-5 w-5 rounded-full text-[9px]" : "h-6 w-6 rounded-[7px] text-[10px]",
|
||||
)}
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
|
||||
aria-hidden
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", compact ? "h-3.5 w-3.5" : "h-4 w-4")}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className={compact ? "h-3 w-3" : "h-3.5 w-3.5"} strokeWidth={2.25} />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{t(`settings.channels.items.${feature.name}.setup.docsLabel`, {
|
||||
defaultValue: setup.docsLabel ?? tx("settings.channels.officialGuide", "Official guide"),
|
||||
})}
|
||||
</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupLinks({
|
||||
feature,
|
||||
setup,
|
||||
chatAppsDocsUrl,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
chatAppsDocsUrl?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<ChannelOfficialLink feature={feature} setup={setup} />
|
||||
<ChannelGuideLink feature={feature} setup={setup} chatAppsDocsUrl={chatAppsDocsUrl} compact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelOfficialLink({
|
||||
feature,
|
||||
setup,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
}) {
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const logoUrls = useMemo(
|
||||
() => logoFallbackUrls(setup.docsLogoUrl ?? presentation?.logoUrl),
|
||||
[presentation?.logoUrl, setup.docsLogoUrl],
|
||||
);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const Icon = presentation?.icon;
|
||||
const color = presentation?.color ?? "#6B7280";
|
||||
const label = setup.officialLabel;
|
||||
if (!setup.officialUrl || !label) return null;
|
||||
return (
|
||||
<a
|
||||
href={setup.officialUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full shrink-0 items-center gap-2 rounded-full border border-border/65 bg-background/90 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground shadow-sm transition-colors hover:border-border hover:bg-muted/45"
|
||||
>
|
||||
<span
|
||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background"
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}16` }}
|
||||
aria-hidden
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className="h-3 w-3" strokeWidth={2.25} />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupActions({
|
||||
feature,
|
||||
setup,
|
||||
onNotice,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
setup: ChannelSetupPresentation;
|
||||
onNotice: (message: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!setup.actions?.length) return null;
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{setup.actions.map((action) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => {
|
||||
if (action.copyText) {
|
||||
void copyTextToClipboard(action.copyText).then((ok) =>
|
||||
onNotice(
|
||||
ok
|
||||
? t("settings.channels.helperCopied", {
|
||||
name: action.label,
|
||||
defaultValue: "{{name}} copied.",
|
||||
})
|
||||
: t("settings.channels.helperCopyFailed", {
|
||||
name: action.label,
|
||||
defaultValue: "Could not copy {{name}}.",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{action.copyText ? <Clipboard className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
<span className="sr-only">
|
||||
{CHANNEL_PRESENTATION[feature.name]?.displayName ?? feature.display_name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelProviderPresets({
|
||||
featureName,
|
||||
presets,
|
||||
onApply,
|
||||
}: {
|
||||
featureName: string;
|
||||
presets: ChannelProviderPreset[];
|
||||
onApply: (preset: ChannelProviderPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState("");
|
||||
if (!presets.length) return null;
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 text-[11px] font-medium text-foreground/85">
|
||||
{t(`settings.channels.items.${featureName}.providerPreset`, {
|
||||
defaultValue: "Provider",
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t(`settings.channels.items.${featureName}.providerPreset`, {
|
||||
defaultValue: "Provider",
|
||||
})}
|
||||
className="grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
|
||||
style={{ gridTemplateColumns: `repeat(${presets.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected === preset.id}
|
||||
onClick={() => {
|
||||
setSelected(preset.id);
|
||||
onApply(preset);
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
||||
selected === preset.id
|
||||
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelValidationBadge({
|
||||
validation,
|
||||
validating,
|
||||
feature,
|
||||
}: {
|
||||
validation: ChannelValidationPayload | null;
|
||||
validating: boolean;
|
||||
feature: NanobotFeatureInfo;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const status = validation?.status ?? (feature.configured ? "configured" : "needs_setup");
|
||||
const label = validating
|
||||
? t("settings.channels.checking", { defaultValue: "Checking..." })
|
||||
: channelValidationStatusLabel(status, t);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
|
||||
channelValidationStatusClass(status),
|
||||
)}
|
||||
>
|
||||
{validating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
channelValidationStatusIcon(status)
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelValidationDetails({ validation }: { validation: ChannelValidationPayload | null }) {
|
||||
const message = validation?.message;
|
||||
if (!validation?.identity?.name && !message) return null;
|
||||
return (
|
||||
<div className="mt-2 truncate text-[11.5px] text-muted-foreground">
|
||||
{validation?.identity?.name
|
||||
? validation.identity.workspace
|
||||
? `${validation.identity.name} · ${validation.identity.workspace}`
|
||||
: validation.identity.name
|
||||
: message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelValidationChecks({ validation }: { validation: ChannelValidationPayload }) {
|
||||
if (!validation.checks.length) return null;
|
||||
return (
|
||||
<div className="border-t border-border/60 px-4 py-4">
|
||||
<div className="mb-2 text-[12px] font-semibold text-foreground">Connection checks</div>
|
||||
<div className="space-y-2">
|
||||
{validation.checks.slice(0, 6).map((check) => (
|
||||
<div key={check.id} className="flex gap-2 text-[12px] leading-5">
|
||||
<span className={cn("mt-0.5", channelValidationCheckIconClass(check.status))}>
|
||||
{channelValidationCheckIcon(check.status)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-foreground/85">{check.label}</div>
|
||||
{check.message ? (
|
||||
<div className="text-muted-foreground">{check.message}</div>
|
||||
) : null}
|
||||
{check.action_url ? (
|
||||
<a
|
||||
href={check.action_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-foreground underline decoration-border underline-offset-4"
|
||||
>
|
||||
Open
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelSetupSteps({
|
||||
featureName,
|
||||
steps,
|
||||
action,
|
||||
tryIt,
|
||||
}: {
|
||||
featureName: string;
|
||||
steps: string[];
|
||||
action?: ReactNode;
|
||||
tryIt?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className="border-t border-border/60 px-4 py-4 text-[12.5px] leading-5 text-muted-foreground">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-[12px] font-semibold text-foreground">
|
||||
{tx("settings.channels.setupSteps", "Next steps")}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<ol className="space-y-1.5">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step} className="flex gap-2">
|
||||
<span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-background text-[10px] font-semibold text-muted-foreground shadow-sm">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>
|
||||
{t(`settings.channels.items.${featureName}.setup.steps.${index}`, {
|
||||
defaultValue: step,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{tryIt ? (
|
||||
<div className="mt-3 rounded-[12px] border border-border/55 bg-background px-3 py-2 text-[12px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{tx("settings.channels.tryIt", "Try it")}
|
||||
</span>
|
||||
<span className="ml-2">
|
||||
{t(`settings.channels.items.${featureName}.setup.tryIt`, { defaultValue: tryIt })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Check, CircleAlert, Eye, EyeOff, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
|
||||
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
|
||||
}
|
||||
|
||||
export function defaultChannelFieldValues(
|
||||
fields: ChannelConfigField[],
|
||||
configValues: Record<string, string> | undefined = undefined,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
fields.map((field) => [
|
||||
field.key,
|
||||
configValues?.[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function channelValuesForSave(
|
||||
fields: ChannelConfigField[],
|
||||
values: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const value = channelFieldValue(field, values);
|
||||
if (field.secret && !value.trim()) continue;
|
||||
payload[field.key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function channelValuesForSubmit(
|
||||
fields: ChannelConfigField[],
|
||||
values: Record<string, string>,
|
||||
touchedFields: Set<string>,
|
||||
): Record<string, string> {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const touched = touchedFields.has(field.key);
|
||||
const value = channelFieldValue(field, values);
|
||||
if (field.secret && !value.trim()) continue;
|
||||
if (!touched && !value.trim()) continue;
|
||||
if (!touched && field.options?.length) continue;
|
||||
payload[field.key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function channelValidationStatusLabel(
|
||||
status: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): string {
|
||||
const labels: Record<string, string> = {
|
||||
connected: "Connected",
|
||||
configured: "Configured manually",
|
||||
needs_setup: "Needs setup",
|
||||
invalid: "Invalid",
|
||||
unsupported: "Manual setup",
|
||||
};
|
||||
return t(`settings.channels.validation.${status}`, {
|
||||
defaultValue: labels[status] ?? "Checked",
|
||||
});
|
||||
}
|
||||
|
||||
export function channelValidationStatusClass(status: string): string {
|
||||
if (status === "connected") {
|
||||
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200";
|
||||
}
|
||||
if (status === "configured") {
|
||||
return "bg-blue-500/10 text-blue-700 dark:text-blue-200";
|
||||
}
|
||||
if (status === "invalid") {
|
||||
return "bg-destructive/10 text-destructive";
|
||||
}
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
|
||||
export function channelValidationStatusIcon(status: string): ReactNode {
|
||||
if (status === "connected" || status === "configured") {
|
||||
return <Check className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
if (status === "invalid") {
|
||||
return <X className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
|
||||
export function channelValidationCheckIcon(status: string): ReactNode {
|
||||
if (status === "pass") return <Check className="h-3.5 w-3.5" aria-hidden />;
|
||||
if (status === "fail") return <X className="h-3.5 w-3.5" aria-hidden />;
|
||||
if (status === "warn") return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
|
||||
return <CircleAlert className="h-3.5 w-3.5" aria-hidden />;
|
||||
}
|
||||
|
||||
export function channelValidationCheckIconClass(status: string): string {
|
||||
if (status === "pass") return "text-emerald-600";
|
||||
if (status === "fail") return "text-destructive";
|
||||
if (status === "warn") return "text-amber-600";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
export function CredentialForm({
|
||||
fields,
|
||||
values,
|
||||
configuredFields,
|
||||
visibleSecrets,
|
||||
onChange,
|
||||
onToggleSecret,
|
||||
compact = false,
|
||||
}: {
|
||||
fields: ChannelConfigField[];
|
||||
values: Record<string, string>;
|
||||
configuredFields?: Set<string>;
|
||||
visibleSecrets: Record<string, boolean>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onToggleSecret: (key: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
return (
|
||||
<div className={cn(compact ? "space-y-2.5" : "mt-3 space-y-2.5")}>
|
||||
{fields.map((field) => {
|
||||
const visible = Boolean(visibleSecrets[field.key]);
|
||||
const value = values[field.key] ?? "";
|
||||
const savedSecret = Boolean(field.secret && configuredFields?.has(field.key) && !value.trim());
|
||||
const showSecretToggle = Boolean(field.secret && value.trim());
|
||||
const inputType = field.secret && !visible ? "password" : field.inputType ?? "text";
|
||||
const selectedOption = channelFieldValue(field, values);
|
||||
const header = (
|
||||
<span className="flex items-center justify-between gap-2 text-[11px] font-medium text-foreground/85">
|
||||
<span>{field.label}</span>
|
||||
{savedSecret ? (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{tx("settings.channels.savedSecret", "Saved")}
|
||||
</span>
|
||||
) : field.optional && !compact ? (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{tx("settings.channels.optional", "Optional")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
const help = field.help ? (
|
||||
<span className="mt-1 block text-[11px] leading-4 text-muted-foreground">
|
||||
{field.help}
|
||||
</span>
|
||||
) : null;
|
||||
if (field.options?.length) {
|
||||
return (
|
||||
<div key={field.key} className="block">
|
||||
{header}
|
||||
<span
|
||||
role="radiogroup"
|
||||
aria-label={field.label}
|
||||
className="mt-1 grid rounded-[10px] bg-muted/75 p-0.5 text-[12px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_rgba(15,23,42,0.035)]"
|
||||
style={{ gridTemplateColumns: `repeat(${field.options.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selectedOption === option.value}
|
||||
onClick={() => onChange(field.key, option.value)}
|
||||
className={cn(
|
||||
"min-h-8 rounded-[8px] px-2 py-1.5 transition-colors hover:text-foreground",
|
||||
selectedOption === option.value
|
||||
&& "bg-background text-foreground shadow-[0_1px_2px_rgba(15,23,42,0.10),inset_0_0_0_1px_rgba(15,23,42,0.055)]",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
{help}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<label key={field.key} className="block">
|
||||
{header}
|
||||
<span className="relative mt-1 block">
|
||||
<Input
|
||||
aria-label={field.label}
|
||||
type={inputType}
|
||||
inputMode={field.inputType === "number" ? "numeric" : undefined}
|
||||
placeholder={
|
||||
savedSecret
|
||||
? tx("settings.channels.savedSecretPlaceholder", "Saved secret")
|
||||
: field.placeholder
|
||||
}
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
className={cn(
|
||||
"h-9 rounded-[10px] border-border/60 bg-muted/35 text-[13px]",
|
||||
showSecretToggle && "pr-9",
|
||||
)}
|
||||
/>
|
||||
{showSecretToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
visible
|
||||
? tx("settings.channels.hideSecret", "Hide secret")
|
||||
: tx("settings.channels.showSecret", "Show secret")
|
||||
}
|
||||
onClick={() => onToggleSecret(field.key)}
|
||||
className="absolute right-2 top-1/2 grid h-6 w-6 -translate-y-1/2 place-items-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground"
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" aria-hidden />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
{help}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Loader2, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||
import {
|
||||
CHANNEL_PRESENTATION,
|
||||
type ChannelConfigField,
|
||||
} from "@/components/settings/channels/catalog";
|
||||
import {
|
||||
CredentialForm,
|
||||
channelValidationStatusClass,
|
||||
channelValidationStatusIcon,
|
||||
channelValuesForSave,
|
||||
defaultChannelFieldValues,
|
||||
} from "@/components/settings/channels/CredentialForm";
|
||||
import {
|
||||
ChannelLogo,
|
||||
ChannelStatusBadge,
|
||||
channelDisplayName,
|
||||
channelSetup,
|
||||
channelStatusLabel,
|
||||
} from "@/components/settings/channels/ChannelIdentity";
|
||||
import { FeishuConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||
import {
|
||||
ChannelGuideLink,
|
||||
ChannelSetupSteps,
|
||||
} from "@/components/settings/channels/ChannelSetupParts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import {
|
||||
configureChannel,
|
||||
disableNanobotFeature,
|
||||
enableNanobotFeature,
|
||||
} from "@/lib/api";
|
||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
||||
import type {
|
||||
NanobotChannelInstanceInfo,
|
||||
NanobotFeatureInfo,
|
||||
NanobotFeaturesPayload,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FeishuAssistantsPanel({
|
||||
token,
|
||||
feature,
|
||||
showBrandLogos,
|
||||
chatAppsDocsUrl,
|
||||
onFeaturesUpdate,
|
||||
}: {
|
||||
token: string;
|
||||
feature: NanobotFeatureInfo;
|
||||
showBrandLogos: boolean;
|
||||
chatAppsDocsUrl?: string;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const instances = feishuFeatureInstances(feature);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const selected = selectedId ? instances.find((instance) => instance.id === selectedId) : undefined;
|
||||
const setup = channelSetup(feature);
|
||||
const manualFields = setup.manualFields ?? [];
|
||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
|
||||
feishuInstanceFieldValues(manualFields, selected),
|
||||
);
|
||||
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
|
||||
const [savingFields, setSavingFields] = useState(false);
|
||||
const connectedAssistantCount = instances.filter((instance) => instance.configured).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId && !instances.some((instance) => instance.id === selectedId)) {
|
||||
setSelectedId(null);
|
||||
}
|
||||
}, [instances, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValues(feishuInstanceFieldValues(manualFields, selected));
|
||||
setVisibleSecrets({});
|
||||
}, [
|
||||
manualFields,
|
||||
selected?.allow_from,
|
||||
selected?.app_id,
|
||||
selected?.domain,
|
||||
selected?.group_policy,
|
||||
selected?.id,
|
||||
]);
|
||||
|
||||
const toggleInstance = async (instance: NanobotChannelInstanceInfo, checked: boolean) => {
|
||||
setBusyInstanceId(instance.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = checked
|
||||
? await enableNanobotFeature(token, "feishu", { instanceId: instance.id })
|
||||
: await disableNanobotFeature(token, "feishu", { instanceId: instance.id });
|
||||
onFeaturesUpdate(payload);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reconnectInstance = async (instance: NanobotChannelInstanceInfo) => {
|
||||
setBusyInstanceId(instance.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await enableNanobotFeature(token, "feishu", { instanceId: instance.id });
|
||||
onFeaturesUpdate(payload);
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSelectedInstanceSettings = async () => {
|
||||
if (!selected) return;
|
||||
setSavingFields(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const payload = await configureChannel(
|
||||
token,
|
||||
"feishu",
|
||||
channelValuesForSave(manualFields, fieldValues),
|
||||
{ enable: selected.enabled, instanceId: selected.id },
|
||||
);
|
||||
if (payload.nanobot_features) {
|
||||
onFeaturesUpdate(payload.nanobot_features);
|
||||
}
|
||||
setNotice(tx("settings.channels.savedSettings", "Saved settings."));
|
||||
} catch (err) {
|
||||
setNotice((err as Error).message);
|
||||
} finally {
|
||||
setSavingFields(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="min-h-full rounded-[20px] border border-border/80 bg-background p-5 shadow-none">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<ChannelLogo feature={feature} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
|
||||
{channelDisplayName(feature)}
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
||||
{feishuAssistantCountLabel(connectedAssistantCount, tx)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChannelStatusBadge>{channelStatusLabel(feature, tx)}</ChannelStatusBadge>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{instances.map((instance) => {
|
||||
const expanded = selected?.id === instance.id;
|
||||
return (
|
||||
<article
|
||||
key={instance.id}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[18px] border transition-colors",
|
||||
expanded
|
||||
? "border-border/75 bg-card/95 shadow-sm"
|
||||
: "border-border/55 bg-background hover:border-border/75 hover:bg-muted/15",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() =>
|
||||
setSelectedId((current) => (current === instance.id ? null : instance.id))
|
||||
}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<FeishuAssistantAvatar
|
||||
feature={feature}
|
||||
instance={instance}
|
||||
showBrandLogos={showBrandLogos}
|
||||
size="lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
|
||||
{feishuInstanceDisplayName(instance)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{busyInstanceId === instance.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
|
||||
) : null}
|
||||
<ToggleButton
|
||||
checked={instance.enabled}
|
||||
disabled={busyInstanceId === instance.id || !instance.configured}
|
||||
ariaLabel={t("settings.channels.toggleFeishuAssistant", {
|
||||
name: feishuInstanceDisplayName(instance),
|
||||
defaultValue: "{{name}} assistant",
|
||||
})}
|
||||
label={instance.enabled ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
onChange={(checked) => void toggleInstance(instance, checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="border-t border-border/60">
|
||||
<section className="px-4 py-4">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<p className="min-w-0 flex-1 truncate font-mono text-[11.5px] leading-6 text-muted-foreground">
|
||||
{maskFeishuAppId(instance.app_id) || tx("settings.channels.noAppId", "No App ID")}
|
||||
</p>
|
||||
<FeishuAssistantConnectionBadge instance={instance} />
|
||||
</div>
|
||||
{instance.configured ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void reconnectInstance(instance)}
|
||||
disabled={busyInstanceId === instance.id || !instance.enabled}
|
||||
>
|
||||
{busyInstanceId === instance.id ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{tx("settings.channels.reconnectAssistant", "Reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FeishuConnectFlow
|
||||
key={`connect-${instance.id}`}
|
||||
token={token}
|
||||
instanceId={instance.id}
|
||||
mode="replace"
|
||||
idleLabel={tx("settings.channels.connect", "Connect")}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
<ChannelSetupSteps
|
||||
featureName={feature.name}
|
||||
steps={setup.steps}
|
||||
action={
|
||||
<ChannelGuideLink
|
||||
feature={feature}
|
||||
setup={setup}
|
||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||
compact
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{manualFields.length ? (
|
||||
<details className="group border-t border-border/60 px-4 py-3 text-[12px] leading-5 text-muted-foreground">
|
||||
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{tx("settings.channels.advanced", "Advanced")}
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5 transition-transform group-open:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
<CredentialForm
|
||||
fields={manualFields}
|
||||
values={fieldValues}
|
||||
visibleSecrets={visibleSecrets}
|
||||
onChange={(key, value) =>
|
||||
setFieldValues((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
onToggleSecret={(key) =>
|
||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }))
|
||||
}
|
||||
compact
|
||||
/>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
|
||||
onClick={() => void saveSelectedInstanceSettings()}
|
||||
disabled={savingFields}
|
||||
>
|
||||
{savingFields ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : null}
|
||||
{tx("settings.channels.saveSettings", "Save settings")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
{tx("settings.channels.createFeishuAssistant", "Create another assistant")}
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
|
||||
{tx(
|
||||
"settings.channels.createFeishuAssistantHint",
|
||||
"Create a separate Feishu bot for another team, space, or workflow.",
|
||||
)}
|
||||
</p>
|
||||
<FeishuConnectFlow
|
||||
key="create-feishu-assistant"
|
||||
token={token}
|
||||
instanceId="default"
|
||||
mode="create"
|
||||
idleLabel={tx("settings.channels.createAssistant", "Create assistant")}
|
||||
onFeaturesUpdate={onFeaturesUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function feishuFeatureInstances(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo[] {
|
||||
if (feature.instances?.length) return feature.instances;
|
||||
return [{
|
||||
id: "default",
|
||||
name: "nanobot",
|
||||
domain: "feishu",
|
||||
enabled: feature.enabled,
|
||||
configured: Boolean(feature.configured),
|
||||
app_id: "",
|
||||
}];
|
||||
}
|
||||
|
||||
function feishuAssistantCountLabel(
|
||||
count: number,
|
||||
tx: (key: string, fallback: string) => string,
|
||||
): string {
|
||||
if (count === 0) {
|
||||
return tx("settings.channels.noFeishuAssistants", "No assistant connected");
|
||||
}
|
||||
if (count === 1) {
|
||||
return tx("settings.channels.oneFeishuAssistant", "1 assistant connected");
|
||||
}
|
||||
return tx("settings.channels.manyFeishuAssistants", `${count} assistants connected`);
|
||||
}
|
||||
|
||||
function feishuInstanceDisplayName(instance: NanobotChannelInstanceInfo): string {
|
||||
const displayName = instance.display_name?.trim();
|
||||
if (displayName) return displayName;
|
||||
const localName = instance.name?.trim();
|
||||
if (localName) return localName;
|
||||
return instance.id === "default" ? "nanobot" : "nanobot";
|
||||
}
|
||||
|
||||
function FeishuAssistantConnectionBadge({ instance }: { instance: NanobotChannelInstanceInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const status = instance.configured ? "connected" : "needs_setup";
|
||||
const label = instance.configured
|
||||
? t("settings.channels.feishuConfigured", { defaultValue: "Connected" })
|
||||
: t("settings.channels.feishuNotConfigured", { defaultValue: "Needs authorization" });
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[11.5px] font-medium",
|
||||
channelValidationStatusClass(status),
|
||||
)}
|
||||
>
|
||||
{channelValidationStatusIcon(status)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FeishuAssistantAvatar({
|
||||
feature,
|
||||
instance,
|
||||
showBrandLogos,
|
||||
size,
|
||||
}: {
|
||||
feature: NanobotFeatureInfo;
|
||||
instance: NanobotChannelInstanceInfo;
|
||||
showBrandLogos: boolean;
|
||||
size: "sm" | "lg";
|
||||
}) {
|
||||
const presentation = CHANNEL_PRESENTATION[feature.name];
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
const fallbackLogoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(fallbackLogoUrls);
|
||||
const remoteAvatarUrl = !avatarFailed ? instance.avatar_url?.trim() : "";
|
||||
const imageUrl = remoteAvatarUrl || (showBrandLogos ? logoUrl : "");
|
||||
const Icon = presentation?.icon;
|
||||
const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase();
|
||||
const color = presentation?.color ?? "#3370FF";
|
||||
const frameClass = size === "lg" ? "h-11 w-11" : "h-9 w-9";
|
||||
const fallbackImageClass = size === "lg" ? "h-6 w-6" : "h-5 w-5";
|
||||
const iconClass = size === "lg" ? "h-5 w-5" : "h-4 w-4";
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarFailed(false);
|
||||
}, [instance.avatar_url]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"grid shrink-0 place-items-center overflow-hidden rounded-full border border-border/45 bg-background text-[10px] font-bold",
|
||||
frameClass,
|
||||
)}
|
||||
style={{ color, boxShadow: `inset 0 0 0 1px ${color}18` }}
|
||||
aria-hidden
|
||||
>
|
||||
{remoteAvatarUrl ? (
|
||||
<img
|
||||
src={remoteAvatarUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setAvatarFailed(true)}
|
||||
/>
|
||||
) : imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", fallbackImageClass)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : Icon ? (
|
||||
<Icon className={iconClass} strokeWidth={2.25} />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function maskFeishuAppId(appId: string | undefined): string {
|
||||
if (!appId) return "";
|
||||
if (appId.length <= 10) return appId;
|
||||
return `${appId.slice(0, 7)}...${appId.slice(-4)}`;
|
||||
}
|
||||
|
||||
function feishuInstanceFieldValues(
|
||||
fields: ChannelConfigField[],
|
||||
instance: NanobotChannelInstanceInfo | undefined,
|
||||
): Record<string, string> {
|
||||
const values = defaultChannelFieldValues(fields);
|
||||
if (!instance) return values;
|
||||
values["channels.feishu.appId"] = instance.app_id ?? "";
|
||||
values["channels.feishu.appSecret"] = "";
|
||||
values["channels.feishu.domain"] = instance.domain ?? values["channels.feishu.domain"] ?? "feishu";
|
||||
values["channels.feishu.groupPolicy"] =
|
||||
instance.group_policy ?? values["channels.feishu.groupPolicy"] ?? "mention";
|
||||
values["channels.feishu.allowFrom"] = (instance.allow_from ?? []).join(", ");
|
||||
return values;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ import {
|
||||
type ActivityEvidence,
|
||||
} from "@/lib/activity-timeline";
|
||||
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { hasRenderableFileDiff } from "@/lib/file-diff";
|
||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
|
||||
@@ -943,10 +944,12 @@ function TraceIconMark({
|
||||
fallbackIcon: LucideIcon;
|
||||
active: boolean;
|
||||
}) {
|
||||
const [faviconIndex, setFaviconIndex] = useState(0);
|
||||
const faviconUrl = trace.host ? faviconUrls(trace.host)[faviconIndex] : undefined;
|
||||
|
||||
useEffect(() => setFaviconIndex(0), [trace.host]);
|
||||
const faviconCandidates = useMemo(() => (trace.host ? faviconUrls(trace.host) : []), [trace.host]);
|
||||
const {
|
||||
logoUrl: faviconUrl,
|
||||
onLogoError: onFaviconError,
|
||||
onLogoLoad: onFaviconLoad,
|
||||
} = useLogoFallback(faviconCandidates);
|
||||
|
||||
if (trace.url && trace.host && faviconUrl) {
|
||||
return (
|
||||
@@ -962,7 +965,10 @@ function TraceIconMark({
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="h-3.5 w-3.5 object-contain"
|
||||
onError={() => setFaviconIndex((index) => index + 1)}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
onLoad={onFaviconLoad}
|
||||
onError={onFaviconError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
@@ -1661,19 +1667,16 @@ function CliRunGroup({
|
||||
|
||||
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const args = formatCliArgs(run);
|
||||
const failed = run.status === "error";
|
||||
const rowActive = active && run.status === "running";
|
||||
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(app?.logo_url), [app?.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const label = t(cliRunLabelKey(run, active), {
|
||||
defaultValue: cliRunLabelDefault(run, active),
|
||||
});
|
||||
|
||||
useEffect(() => setLogoIndex(0), [app?.logo_url]);
|
||||
|
||||
return (
|
||||
<ActivityStep
|
||||
as="li"
|
||||
@@ -1699,8 +1702,11 @@ function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean;
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-[78%] w-[78%] object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : app ? (
|
||||
cliAppInitials(app).slice(0, 2)
|
||||
@@ -1772,19 +1778,16 @@ function McpRunGroup({
|
||||
|
||||
function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolean; preset?: McpPresetInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const failed = run.status === "error";
|
||||
const rowActive = active && run.status === "running";
|
||||
const color = failed ? "#DC2626" : preset?.brand_color || "#6D5DF6";
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(preset?.logo_url), [preset?.logo_url]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const displayName = preset?.display_name || run.displayName;
|
||||
const label = t(mcpRunLabelKey(run, active), {
|
||||
defaultValue: mcpRunLabelDefault(run, active),
|
||||
});
|
||||
|
||||
useEffect(() => setLogoIndex(0), [preset?.logo_url]);
|
||||
|
||||
return (
|
||||
<ActivityStep
|
||||
as="li"
|
||||
@@ -1810,8 +1813,11 @@ function McpRunRow({ run, active, preset }: { run: McpRunSummary; active: boolea
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-[78%] w-[78%] object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : preset ? (
|
||||
mcpPresetInitials(preset).slice(0, 2)
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type RestoredReadyImage,
|
||||
} from "@/hooks/useAttachedImages";
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||
import type {
|
||||
@@ -2260,15 +2261,12 @@ function ComposerModelBadge({
|
||||
}) {
|
||||
const inferredProvider = needsSetup ? null : provider || inferProviderFromModelName(label);
|
||||
const brand = providerBrand(inferredProvider);
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const logoUrl = brand?.logoUrls[logoIndex];
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(brand?.logoUrls);
|
||||
const showLogo = !!logoUrl;
|
||||
const title = providerLabel ? `${label} · ${providerLabel}` : label;
|
||||
const interactive = Boolean(onClick);
|
||||
const Container = interactive ? "button" : "span";
|
||||
|
||||
useEffect(() => setLogoIndex(0), [inferredProvider]);
|
||||
|
||||
return (
|
||||
<Container
|
||||
title={title}
|
||||
@@ -2305,8 +2303,11 @@ function ComposerModelBadge({
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className={cn("object-contain", isHero ? "h-3 w-3" : "h-3.5 w-3.5")}
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
) : brand ? (
|
||||
<span
|
||||
@@ -2502,15 +2503,12 @@ function MentionCandidateLogo({
|
||||
candidate: MentionCandidate;
|
||||
selected: boolean;
|
||||
}) {
|
||||
const [logoIndex, setLogoIndex] = useState(0);
|
||||
const color = (candidate.kind === "cli"
|
||||
? candidate.app.brand_color
|
||||
: candidate.preset.brand_color) || "hsl(var(--primary))";
|
||||
const rawLogoUrl = candidate.kind === "cli" ? candidate.app.logo_url : candidate.preset.logo_url;
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(rawLogoUrl), [rawLogoUrl]);
|
||||
const logoUrl = logoUrls[logoIndex];
|
||||
|
||||
useEffect(() => setLogoIndex(0), [rawLogoUrl]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
|
||||
if (logoUrl) {
|
||||
return (
|
||||
@@ -2523,8 +2521,11 @@ function MentionCandidateLogo({
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-5 w-5 object-contain"
|
||||
onError={() => setLogoIndex((index) => index + 1)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
const loadedLogoUrls = new Set<string>();
|
||||
const failedLogoUrls = new Set<string>();
|
||||
const resolvedLogoIndexByKey = new Map<string, number>();
|
||||
|
||||
function logoCacheKey(urls: readonly string[]): string {
|
||||
return urls.join("\n");
|
||||
}
|
||||
|
||||
function logoUrlsFromKey(key: string): string[] {
|
||||
return key ? key.split("\n") : [];
|
||||
}
|
||||
|
||||
function firstUsableLogoIndex(urls: readonly string[]): number {
|
||||
const key = logoCacheKey(urls);
|
||||
const cachedIndex = resolvedLogoIndexByKey.get(key);
|
||||
if (
|
||||
typeof cachedIndex === "number" &&
|
||||
cachedIndex >= 0 &&
|
||||
cachedIndex < urls.length &&
|
||||
!failedLogoUrls.has(urls[cachedIndex])
|
||||
) {
|
||||
return cachedIndex;
|
||||
}
|
||||
|
||||
const loadedIndex = urls.findIndex((url) => loadedLogoUrls.has(url));
|
||||
if (loadedIndex >= 0) {
|
||||
resolvedLogoIndexByKey.set(key, loadedIndex);
|
||||
return loadedIndex;
|
||||
}
|
||||
|
||||
const firstUnfailedIndex = urls.findIndex((url) => !failedLogoUrls.has(url));
|
||||
if (firstUnfailedIndex >= 0) return firstUnfailedIndex;
|
||||
return -1;
|
||||
}
|
||||
|
||||
function nextLogoIndex(urls: readonly string[], afterIndex: number): number {
|
||||
for (let index = afterIndex + 1; index < urls.length; index += 1) {
|
||||
if (!failedLogoUrls.has(urls[index])) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function useLogoFallback(urls: readonly string[] | undefined) {
|
||||
const cacheKey = useMemo(() => logoCacheKey(urls?.filter(Boolean) ?? []), [urls]);
|
||||
const safeUrls = useMemo(() => logoUrlsFromKey(cacheKey), [cacheKey]);
|
||||
const [logoIndex, setLogoIndex] = useState(() => firstUsableLogoIndex(safeUrls));
|
||||
const logoUrl = logoIndex >= 0 ? safeUrls[logoIndex] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setLogoIndex(firstUsableLogoIndex(safeUrls));
|
||||
}, [cacheKey, safeUrls]);
|
||||
|
||||
const onLogoLoad = useCallback(() => {
|
||||
if (!logoUrl || logoIndex < 0) return;
|
||||
loadedLogoUrls.add(logoUrl);
|
||||
failedLogoUrls.delete(logoUrl);
|
||||
resolvedLogoIndexByKey.set(cacheKey, logoIndex);
|
||||
}, [cacheKey, logoIndex, logoUrl]);
|
||||
|
||||
const onLogoError = useCallback(() => {
|
||||
if (!logoUrl || logoIndex < 0) return;
|
||||
failedLogoUrls.add(logoUrl);
|
||||
if (resolvedLogoIndexByKey.get(cacheKey) === logoIndex) {
|
||||
resolvedLogoIndexByKey.delete(cacheKey);
|
||||
}
|
||||
setLogoIndex(nextLogoIndex(safeUrls, logoIndex));
|
||||
}, [cacheKey, logoIndex, logoUrl, safeUrls]);
|
||||
|
||||
return { logoUrl, onLogoLoad, onLogoError };
|
||||
}
|
||||
|
||||
export function __clearLogoFallbackCacheForTests(): void {
|
||||
loadedLogoUrls.clear();
|
||||
failedLogoUrls.clear();
|
||||
resolvedLogoIndexByKey.clear();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string, fallback = false): boolean {
|
||||
const readMatch = () => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return window.matchMedia(query).matches;
|
||||
};
|
||||
|
||||
const [matches, setMatches] = useState(readMatch);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
typeof window.matchMedia !== "function"
|
||||
)
|
||||
return;
|
||||
const media = window.matchMedia(query);
|
||||
const update = () => setMatches(media.matches);
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Image",
|
||||
"voice": "Voice",
|
||||
"browser": "Web",
|
||||
"channels": "Channels",
|
||||
"cliApps": "CLI Apps",
|
||||
"mcp": "MCP",
|
||||
"runtime": "System",
|
||||
@@ -236,21 +237,21 @@
|
||||
"searchPlaceholder": "Search CLIs",
|
||||
"loading": "Loading CLI Apps...",
|
||||
"empty": "No CLI Apps match this filter.",
|
||||
"statusInstalled": "CLI installed",
|
||||
"statusInstalled": "App ready",
|
||||
"statusMissing": "Missing",
|
||||
"statusAvailable": "Available",
|
||||
"statusUnsupported": "Unsupported",
|
||||
"statusNotInstalled": "CLI not installed",
|
||||
"statusNotInstalled": "App not installed",
|
||||
"requires": "Requires",
|
||||
"test": "Test CLI",
|
||||
"update": "Update CLI",
|
||||
"uninstall": "Uninstall CLI",
|
||||
"install": "Install CLI",
|
||||
"test": "Test app",
|
||||
"update": "Update app",
|
||||
"uninstall": "Uninstall app",
|
||||
"install": "Install app",
|
||||
"readyTitle": "@{{name}} is ready",
|
||||
"readyStatus": "Ready",
|
||||
"readyTry": "Try @{{name}}",
|
||||
"readyCopied": "Copied",
|
||||
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
|
||||
"readyPrompt": "Ask nanobot to use @{{name}} for this task.",
|
||||
"openChat": "Open chat",
|
||||
"unsupported": "Unsupported",
|
||||
"unavailable": "Unavailable",
|
||||
@@ -270,8 +271,8 @@
|
||||
"filterInstalled": "Enabled",
|
||||
"filterNotInstalled": "Not enabled",
|
||||
"searchPlaceholder": "Search MCP presets",
|
||||
"moreOptions": "More MCP options",
|
||||
"moreOptionsSubtitle": "Add a custom server or import mcp.json.",
|
||||
"moreOptions": "Add integration",
|
||||
"moreOptionsSubtitle": "Connect a custom tool server or import an existing configuration.",
|
||||
"customTitle": "Custom MCP",
|
||||
"customSubtitle": "Add any stdio, HTTP, or SSE MCP server.",
|
||||
"customAction": "Custom",
|
||||
@@ -462,23 +463,77 @@
|
||||
"configureProvider": "Configure provider",
|
||||
"missingCredential": "Configure this provider before enabling image generation."
|
||||
},
|
||||
"api": {
|
||||
"title": "API server",
|
||||
"openaiCompatible": "OpenAI-compatible API",
|
||||
"description": "Connect SDKs and agents through a local /v1 endpoint.",
|
||||
"start": "Start API server",
|
||||
"starting": "Starting...",
|
||||
"stop": "Stop",
|
||||
"stopping": "Stopping...",
|
||||
"access": "Access",
|
||||
"thisDevice": "This device",
|
||||
"localNetwork": "Local network",
|
||||
"localHelp": "Only this device can connect.",
|
||||
"networkHelp": "Other devices can connect; an API key is required.",
|
||||
"port": "Port",
|
||||
"portHelp": "The API uses this local port.",
|
||||
"apiKey": "API key",
|
||||
"apiKeyHelp": "Clients send this as a Bearer token.",
|
||||
"apiKeyRequired": "Required before exposing the API to your network.",
|
||||
"apiKeyPlaceholder": "Enter an API key",
|
||||
"autoInstall": "API support will be installed automatically when you start it."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observability",
|
||||
"configured": "Tracing credentials are available to nanobot.",
|
||||
"environment": "Set LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY, then restart nanobot.",
|
||||
"enable": "Enable tracing support"
|
||||
},
|
||||
"apps": {
|
||||
"description": "Enable plugins, local app adapters, and connected tool servers.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Add tools to nanobot, then @ them in chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Integration",
|
||||
"channelLabel": "Channel",
|
||||
"featureLabel": "Feature",
|
||||
"filterAll": "All",
|
||||
"filterAll": "Ready",
|
||||
"filterPlugins": "Plugins",
|
||||
"filterCli": "CLI apps",
|
||||
"filterMcp": "MCP services",
|
||||
"enabledSummary": "{{count}} enabled",
|
||||
"caption": "{{plugins}} Plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Search Apps",
|
||||
"featured": "Catalog",
|
||||
"filterCli": "Apps",
|
||||
"filterMcp": "Integrations",
|
||||
"enabledSummary": "{{count}} ready",
|
||||
"caption": "{{cli}} apps · {{mcp}} integrations",
|
||||
"searchPlaceholder": "Search tools",
|
||||
"featured": "Tools",
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No apps match this filter.",
|
||||
"restartRequired": "Restart nanobot to apply updated apps and features."
|
||||
"empty": "No tools match this view.",
|
||||
"restartRequired": "Restart nanobot to apply updated apps and integrations."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Connect chat apps, email, and WebUI to nanobot.",
|
||||
"caption": "{{enabled}} enabled · {{total}} channels",
|
||||
"searchPlaceholder": "Search channels",
|
||||
"backToChannels": "All channels",
|
||||
"catalog": "Channels",
|
||||
"loading": "Loading Channels...",
|
||||
"empty": "No channels match this filter.",
|
||||
"restartRequired": "Restart nanobot to apply updated channel support.",
|
||||
"requires": "Requires: {{requirements}}",
|
||||
"setUp": "Set up",
|
||||
"setupGuide": "Setup guide",
|
||||
"setupSummary": "Enable only turns on nanobot support. Add the platform credentials, then restart nanobot.",
|
||||
"configKeys": "Config keys",
|
||||
"enable": "Enable channel",
|
||||
"disable": "Disable channel",
|
||||
"needsConfig": "Needs setup",
|
||||
"connect": "Connect",
|
||||
"reconnect": "Reconnect",
|
||||
"feishuQrAlt": "Feishu connection QR code",
|
||||
"feishuScanTitle": "Scan with Feishu",
|
||||
"feishuScanDescription": "Use Feishu or Lark on your phone to scan this code. nanobot will finish setup automatically after authorization.",
|
||||
"feishuWaiting": "Waiting for authorization...",
|
||||
"feishuConnected": "Feishu is connected.",
|
||||
"feishuConnectStopped": "Connection stopped.",
|
||||
"feishuConnecting": "Connecting..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Enabled",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Imagen",
|
||||
"voice": "Voz",
|
||||
"browser": "Internet",
|
||||
"channels": "Canales",
|
||||
"runtime": "Sistema",
|
||||
"advanced": "Seguridad",
|
||||
"cliApps": "Apps CLI",
|
||||
@@ -459,27 +460,68 @@
|
||||
"noTools": "Ninguna",
|
||||
"testForTools": "Ejecuta Probar para inspeccionar y elegir herramientas individuales."
|
||||
},
|
||||
"api": {
|
||||
"title": "Servidor API", "openaiCompatible": "API compatible con OpenAI",
|
||||
"description": "Conecta SDK y agentes mediante un endpoint /v1 local.",
|
||||
"start": "Iniciar servidor API", "starting": "Iniciando...", "stop": "Detener", "stopping": "Deteniendo...",
|
||||
"access": "Acceso", "thisDevice": "Este dispositivo", "localNetwork": "Red local",
|
||||
"localHelp": "Solo este dispositivo puede conectarse.", "networkHelp": "Otros dispositivos pueden conectarse; se requiere una clave API.",
|
||||
"port": "Puerto", "portHelp": "La API usa este puerto local.", "apiKey": "Clave API",
|
||||
"apiKeyHelp": "Los clientes la envían como token Bearer.", "apiKeyRequired": "Obligatoria antes de exponer la API en la red.",
|
||||
"apiKeyPlaceholder": "Introduce una clave API", "autoInstall": "El soporte API se instalará automáticamente al iniciarlo."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.",
|
||||
"environment": "Configura LANGFUSE_SECRET_KEY y LANGFUSE_PUBLIC_KEY y reinicia nanobot.", "enable": "Habilitar soporte de trazas"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Activa complementos, adaptadores locales de apps y servidores de herramientas conectados.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Integración",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Función",
|
||||
"filterAll": "Todo",
|
||||
"filterAll": "Listo",
|
||||
"filterPlugins": "Complementos",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Servicios MCP",
|
||||
"enabledSummary": "{{count}} activados",
|
||||
"caption": "{{plugins}} complementos · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Apps",
|
||||
"filterMcp": "Integraciones",
|
||||
"enabledSummary": "{{count}} listos",
|
||||
"caption": "{{cli}} apps · {{mcp}} integraciones",
|
||||
"searchPlaceholder": "Buscar apps",
|
||||
"featured": "Catálogo",
|
||||
"featured": "Herramientas",
|
||||
"loading": "Cargando apps...",
|
||||
"empty": "Ninguna app coincide con este filtro.",
|
||||
"empty": "Ninguna herramienta coincide con esta vista.",
|
||||
"restartRequired": "Reinicia nanobot para aplicar apps y funciones actualizadas."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Conecta nanobot con apps de chat. Instalar soporte solo añade el paquete de integración; la mayoría de canales aún necesitan tokens o configuración del espacio de trabajo.",
|
||||
"caption": "{{enabled}} activados · {{total}} canales",
|
||||
"searchPlaceholder": "Buscar canales",
|
||||
"backToChannels": "Todos los canales",
|
||||
"catalog": "Canales",
|
||||
"loading": "Cargando canales...",
|
||||
"empty": "Ningún canal coincide con este filtro.",
|
||||
"restartRequired": "Reinicia nanobot para aplicar el soporte de canales actualizado.",
|
||||
"requires": "Requiere: {{requirements}}",
|
||||
"setUp": "Configurar",
|
||||
"setupGuide": "Guía de configuración",
|
||||
"setupSummary": "Activar solo habilita el soporte en nanobot. Añade las credenciales de la plataforma y reinicia nanobot.",
|
||||
"configKeys": "Claves de configuración",
|
||||
"enable": "Activar canal",
|
||||
"disable": "Desactivar canal",
|
||||
"needsConfig": "Necesita configuración",
|
||||
"connect": "Conectar",
|
||||
"reconnect": "Reconectar",
|
||||
"feishuQrAlt": "Código QR de conexión de Feishu",
|
||||
"feishuScanTitle": "Escanea con Feishu",
|
||||
"feishuScanDescription": "Usa Feishu o Lark en tu teléfono para escanear este código. nanobot terminará la configuración automáticamente después de la autorización.",
|
||||
"feishuWaiting": "Esperando autorización...",
|
||||
"feishuConnected": "Feishu está conectado.",
|
||||
"feishuConnectStopped": "Conexión detenida.",
|
||||
"feishuConnecting": "Conectando..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Activado",
|
||||
"enable": "Activar",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Images",
|
||||
"voice": "Voix",
|
||||
"browser": "Internet",
|
||||
"channels": "Canaux",
|
||||
"runtime": "Système",
|
||||
"advanced": "Sécurité",
|
||||
"cliApps": "Apps CLI",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "Aucun",
|
||||
"testForTools": "Exécutez Tester pour inspecter et choisir des outils individuels."
|
||||
},
|
||||
"api": {
|
||||
"title": "Serveur API", "openaiCompatible": "API compatible OpenAI", "description": "Connectez des SDK et agents via un endpoint /v1 local.",
|
||||
"start": "Démarrer le serveur API", "starting": "Démarrage...", "stop": "Arrêter", "stopping": "Arrêt...",
|
||||
"access": "Accès", "thisDevice": "Cet appareil", "localNetwork": "Réseau local",
|
||||
"localHelp": "Seul cet appareil peut se connecter.", "networkHelp": "D’autres appareils peuvent se connecter ; une clé API est requise.",
|
||||
"port": "Port", "portHelp": "L’API utilise ce port local.", "apiKey": "Clé API", "apiKeyHelp": "Les clients l’envoient comme jeton Bearer.",
|
||||
"apiKeyRequired": "Requise avant d’exposer l’API au réseau.", "apiKeyPlaceholder": "Saisissez une clé API",
|
||||
"autoInstall": "Le support API sera installé automatiquement au démarrage."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.",
|
||||
"environment": "Définissez LANGFUSE_SECRET_KEY et LANGFUSE_PUBLIC_KEY, puis redémarrez nanobot.", "enable": "Activer le traçage"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Activez des extensions, des adaptateurs d’apps locales et des serveurs d’outils connectés.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||
"cliLabel": "App",
|
||||
"mcpLabel": "Intégration",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Fonction",
|
||||
"filterAll": "Tout",
|
||||
"filterAll": "Prêts",
|
||||
"filterPlugins": "Extensions",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Services MCP",
|
||||
"enabledSummary": "{{count}} activés",
|
||||
"caption": "{{plugins}} extensions · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Apps",
|
||||
"filterMcp": "Intégrations",
|
||||
"enabledSummary": "{{count}} prêts",
|
||||
"caption": "{{cli}} apps · {{mcp}} intégrations",
|
||||
"searchPlaceholder": "Rechercher des apps",
|
||||
"featured": "Catalogue",
|
||||
"featured": "Outils",
|
||||
"loading": "Chargement des apps...",
|
||||
"empty": "Aucune app ne correspond.",
|
||||
"empty": "Aucun outil ne correspond à cette vue.",
|
||||
"restartRequired": "Redémarrez nanobot pour appliquer les apps et fonctions mises à jour."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Connectez nanobot aux apps de discussion. L'installation du support ajoute seulement le paquet d'intégration ; la plupart des canaux nécessitent encore des tokens ou des réglages d'espace de travail.",
|
||||
"caption": "{{enabled}} activés · {{total}} canaux",
|
||||
"searchPlaceholder": "Rechercher des canaux",
|
||||
"backToChannels": "Tous les canaux",
|
||||
"catalog": "Canaux",
|
||||
"loading": "Chargement des canaux...",
|
||||
"empty": "Aucun canal ne correspond à ce filtre.",
|
||||
"restartRequired": "Redémarrez nanobot pour appliquer le support de canal mis à jour.",
|
||||
"requires": "Requiert : {{requirements}}",
|
||||
"setUp": "Configurer",
|
||||
"setupGuide": "Guide de configuration",
|
||||
"setupSummary": "L'activation n'active que le support nanobot. Ajoutez les identifiants de la plateforme, puis redémarrez nanobot.",
|
||||
"configKeys": "Clés de configuration",
|
||||
"enable": "Activer le canal",
|
||||
"disable": "Désactiver le canal",
|
||||
"needsConfig": "Configuration requise",
|
||||
"connect": "Connecter",
|
||||
"reconnect": "Reconnecter",
|
||||
"feishuQrAlt": "QR code de connexion Feishu",
|
||||
"feishuScanTitle": "Scanner avec Feishu",
|
||||
"feishuScanDescription": "Utilisez Feishu ou Lark sur votre téléphone pour scanner ce code. nanobot terminera la configuration automatiquement après l'autorisation.",
|
||||
"feishuWaiting": "En attente d'autorisation...",
|
||||
"feishuConnected": "Feishu est connecté.",
|
||||
"feishuConnectStopped": "Connexion arrêtée.",
|
||||
"feishuConnecting": "Connexion..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Gambar",
|
||||
"voice": "Suara",
|
||||
"browser": "Internet",
|
||||
"channels": "Kanal",
|
||||
"runtime": "Sistem",
|
||||
"advanced": "Keamanan",
|
||||
"cliApps": "Aplikasi CLI",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "Tidak ada",
|
||||
"testForTools": "Jalankan Uji untuk memeriksa dan memilih alat individual."
|
||||
},
|
||||
"api": {
|
||||
"title": "Server API", "openaiCompatible": "API kompatibel OpenAI", "description": "Hubungkan SDK dan agen melalui endpoint /v1 lokal.",
|
||||
"start": "Mulai server API", "starting": "Memulai...", "stop": "Hentikan", "stopping": "Menghentikan...",
|
||||
"access": "Akses", "thisDevice": "Perangkat ini", "localNetwork": "Jaringan lokal",
|
||||
"localHelp": "Hanya perangkat ini yang dapat terhubung.", "networkHelp": "Perangkat lain dapat terhubung; kunci API diperlukan.",
|
||||
"port": "Port", "portHelp": "API menggunakan port lokal ini.", "apiKey": "Kunci API", "apiKeyHelp": "Klien mengirimkannya sebagai token Bearer.",
|
||||
"apiKeyRequired": "Wajib sebelum membuka API ke jaringan.", "apiKeyPlaceholder": "Masukkan kunci API",
|
||||
"autoInstall": "Dukungan API akan dipasang otomatis saat dimulai."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.",
|
||||
"environment": "Atur LANGFUSE_SECRET_KEY dan LANGFUSE_PUBLIC_KEY, lalu mulai ulang nanobot.", "enable": "Aktifkan dukungan tracing"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Aktifkan plugin, adaptor aplikasi lokal, dan server alat terhubung.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||
"cliLabel": "Aplikasi",
|
||||
"mcpLabel": "Integrasi",
|
||||
"channelLabel": "Kanal",
|
||||
"featureLabel": "Fitur",
|
||||
"filterAll": "Semua",
|
||||
"filterAll": "Siap",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Aplikasi CLI",
|
||||
"filterMcp": "Layanan MCP",
|
||||
"enabledSummary": "{{count}} aktif",
|
||||
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Aplikasi",
|
||||
"filterMcp": "Integrasi",
|
||||
"enabledSummary": "{{count}} siap",
|
||||
"caption": "{{cli}} aplikasi · {{mcp}} integrasi",
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Katalog",
|
||||
"featured": "Alat",
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada aplikasi yang cocok.",
|
||||
"empty": "Tidak ada alat yang cocok dengan tampilan ini.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan aplikasi dan fitur yang diperbarui."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Hubungkan nanobot ke aplikasi chat. Memasang dukungan hanya menambahkan paket integrasi; sebagian besar kanal tetap memerlukan token atau pengaturan workspace.",
|
||||
"caption": "{{enabled}} aktif · {{total}} kanal",
|
||||
"searchPlaceholder": "Cari kanal",
|
||||
"backToChannels": "Semua kanal",
|
||||
"catalog": "Kanal",
|
||||
"loading": "Memuat kanal...",
|
||||
"empty": "Tidak ada kanal yang cocok dengan filter ini.",
|
||||
"restartRequired": "Mulai ulang nanobot untuk menerapkan dukungan kanal yang diperbarui.",
|
||||
"requires": "Memerlukan: {{requirements}}",
|
||||
"setUp": "Siapkan",
|
||||
"setupGuide": "Panduan setup",
|
||||
"setupSummary": "Mengaktifkan hanya menyalakan dukungan nanobot. Tambahkan kredensial platform, lalu mulai ulang nanobot.",
|
||||
"configKeys": "Kunci konfigurasi",
|
||||
"enable": "Aktifkan kanal",
|
||||
"disable": "Nonaktifkan kanal",
|
||||
"needsConfig": "Perlu konfigurasi",
|
||||
"connect": "Hubungkan",
|
||||
"reconnect": "Hubungkan ulang",
|
||||
"feishuQrAlt": "Kode QR koneksi Feishu",
|
||||
"feishuScanTitle": "Pindai dengan Feishu",
|
||||
"feishuScanDescription": "Gunakan Feishu atau Lark di ponsel untuk memindai kode ini. nanobot akan menyelesaikan setup secara otomatis setelah otorisasi.",
|
||||
"feishuWaiting": "Menunggu otorisasi...",
|
||||
"feishuConnected": "Feishu sudah terhubung.",
|
||||
"feishuConnectStopped": "Koneksi dihentikan.",
|
||||
"feishuConnecting": "Menghubungkan..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Aktif",
|
||||
"enable": "Aktifkan",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "画像",
|
||||
"voice": "音声",
|
||||
"browser": "ウェブ",
|
||||
"channels": "チャンネル",
|
||||
"runtime": "システム",
|
||||
"advanced": "セキュリティ",
|
||||
"cliApps": "CLI アプリ",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "なし",
|
||||
"testForTools": "テストを実行して個別のツールを確認・選択します。"
|
||||
},
|
||||
"api": {
|
||||
"title": "API サーバー", "openaiCompatible": "OpenAI 互換 API", "description": "ローカルの /v1 エンドポイントから SDK やエージェントを接続します。",
|
||||
"start": "API サーバーを起動", "starting": "起動中...", "stop": "停止", "stopping": "停止中...",
|
||||
"access": "アクセス", "thisDevice": "このデバイス", "localNetwork": "ローカルネットワーク",
|
||||
"localHelp": "このデバイスだけが接続できます。", "networkHelp": "他のデバイスも接続できるため API キーが必要です。",
|
||||
"port": "ポート", "portHelp": "API が使用するローカルポートです。", "apiKey": "API キー", "apiKeyHelp": "クライアントは Bearer トークンとして送信します。",
|
||||
"apiKeyRequired": "ネットワークに公開する前に必要です。", "apiKeyPlaceholder": "API キーを入力",
|
||||
"autoInstall": "起動時に API サポートを自動インストールします。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。",
|
||||
"environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "プラグイン、ローカルアプリアダプター、接続済みツールサーバーを有効にします。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||
"cliLabel": "アプリ",
|
||||
"mcpLabel": "連携",
|
||||
"channelLabel": "チャンネル",
|
||||
"featureLabel": "機能",
|
||||
"filterAll": "すべて",
|
||||
"filterAll": "使用可能",
|
||||
"filterPlugins": "プラグイン",
|
||||
"filterCli": "CLI アプリ",
|
||||
"filterMcp": "MCP サービス",
|
||||
"enabledSummary": "{{count}} 件有効",
|
||||
"caption": "{{plugins}} 件のプラグイン · CLI {{cli}} 件 · MCP {{mcp}} 件",
|
||||
"filterCli": "アプリ",
|
||||
"filterMcp": "連携",
|
||||
"enabledSummary": "{{count}} 件使用可能",
|
||||
"caption": "アプリ {{cli}} 件 · 連携 {{mcp}} 件",
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "カタログ",
|
||||
"featured": "ツール",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "一致するアプリはありません。",
|
||||
"empty": "この表示に一致するツールはありません。",
|
||||
"restartRequired": "更新したアプリと機能を反映するには nanobot を再起動してください。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "nanobot をチャットアプリに接続します。サポートのインストールは統合パッケージを追加するだけで、多くのチャンネルでは引き続きトークンやワークスペース設定が必要です。",
|
||||
"caption": "{{enabled}} 件有効 · 全 {{total}} チャンネル",
|
||||
"searchPlaceholder": "チャンネルを検索",
|
||||
"backToChannels": "すべてのチャンネル",
|
||||
"catalog": "チャンネル",
|
||||
"loading": "チャンネルを読み込み中...",
|
||||
"empty": "一致するチャンネルはありません。",
|
||||
"restartRequired": "更新したチャンネルサポートを反映するには nanobot を再起動してください。",
|
||||
"requires": "必要: {{requirements}}",
|
||||
"setUp": "設定",
|
||||
"setupGuide": "設定ガイド",
|
||||
"setupSummary": "有効化は nanobot 側のサポートをオンにするだけです。プラットフォームの認証情報を追加してから nanobot を再起動してください。",
|
||||
"configKeys": "設定キー",
|
||||
"enable": "チャンネルを有効化",
|
||||
"disable": "チャンネルを無効化",
|
||||
"needsConfig": "設定が必要",
|
||||
"connect": "接続",
|
||||
"reconnect": "再接続",
|
||||
"feishuQrAlt": "Feishu 接続 QR コード",
|
||||
"feishuScanTitle": "Feishu でスキャン",
|
||||
"feishuScanDescription": "スマートフォンの Feishu または Lark でこのコードをスキャンしてください。認可後、nanobot が自動で設定を完了します。",
|
||||
"feishuWaiting": "認可を待っています...",
|
||||
"feishuConnected": "Feishu に接続しました。",
|
||||
"feishuConnectStopped": "接続を停止しました。",
|
||||
"feishuConnecting": "接続中..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "有効",
|
||||
"enable": "有効化",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "이미지",
|
||||
"voice": "음성",
|
||||
"browser": "웹",
|
||||
"channels": "채널",
|
||||
"runtime": "시스템",
|
||||
"advanced": "보안",
|
||||
"cliApps": "CLI 앱",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "없음",
|
||||
"testForTools": "테스트를 실행해 개별 도구를 확인하고 선택하세요."
|
||||
},
|
||||
"api": {
|
||||
"title": "API 서버", "openaiCompatible": "OpenAI 호환 API", "description": "로컬 /v1 엔드포인트로 SDK와 에이전트를 연결합니다.",
|
||||
"start": "API 서버 시작", "starting": "시작 중...", "stop": "중지", "stopping": "중지 중...",
|
||||
"access": "접근", "thisDevice": "이 기기", "localNetwork": "로컬 네트워크",
|
||||
"localHelp": "이 기기만 연결할 수 있습니다.", "networkHelp": "다른 기기도 연결할 수 있으므로 API 키가 필요합니다.",
|
||||
"port": "포트", "portHelp": "API가 사용할 로컬 포트입니다.", "apiKey": "API 키", "apiKeyHelp": "클라이언트는 Bearer 토큰으로 전송합니다.",
|
||||
"apiKeyRequired": "네트워크에 공개하기 전에 필요합니다.", "apiKeyPlaceholder": "API 키 입력",
|
||||
"autoInstall": "시작할 때 API 지원을 자동으로 설치합니다."
|
||||
},
|
||||
"observability": {
|
||||
"title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.",
|
||||
"environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
|
||||
},
|
||||
"apps": {
|
||||
"description": "플러그인, 로컬 앱 어댑터, 연결된 도구 서버를 활성화합니다.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||
"cliLabel": "앱",
|
||||
"mcpLabel": "연동",
|
||||
"channelLabel": "채널",
|
||||
"featureLabel": "기능",
|
||||
"filterAll": "전체",
|
||||
"filterAll": "사용 가능",
|
||||
"filterPlugins": "플러그인",
|
||||
"filterCli": "CLI 앱",
|
||||
"filterMcp": "MCP 서비스",
|
||||
"enabledSummary": "{{count}}개 활성화됨",
|
||||
"caption": "플러그인 {{plugins}}개 · CLI {{cli}}개 · MCP {{mcp}}개",
|
||||
"filterCli": "앱",
|
||||
"filterMcp": "연동",
|
||||
"enabledSummary": "{{count}}개 사용 가능",
|
||||
"caption": "앱 {{cli}}개 · 연동 {{mcp}}개",
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "카탈로그",
|
||||
"featured": "도구",
|
||||
"loading": "앱을 불러오는 중...",
|
||||
"empty": "일치하는 앱이 없습니다.",
|
||||
"empty": "이 보기에 일치하는 도구가 없습니다.",
|
||||
"restartRequired": "업데이트된 앱과 기능을 적용하려면 nanobot을 다시 시작하세요."
|
||||
},
|
||||
"channels": {
|
||||
"description": "nanobot을 채팅 앱에 연결합니다. 지원 설치는 통합 패키지만 추가하며, 대부분의 채널은 여전히 토큰이나 워크스페이스 설정이 필요합니다.",
|
||||
"caption": "{{enabled}}개 활성화됨 · 총 {{total}}개 채널",
|
||||
"searchPlaceholder": "채널 검색",
|
||||
"backToChannels": "모든 채널",
|
||||
"catalog": "채널",
|
||||
"loading": "채널을 불러오는 중...",
|
||||
"empty": "일치하는 채널이 없습니다.",
|
||||
"restartRequired": "업데이트된 채널 지원을 적용하려면 nanobot을 다시 시작하세요.",
|
||||
"requires": "필요: {{requirements}}",
|
||||
"setUp": "설정",
|
||||
"setupGuide": "설정 가이드",
|
||||
"setupSummary": "활성화는 nanobot 지원만 켭니다. 플랫폼 자격 증명을 추가한 뒤 nanobot을 다시 시작하세요.",
|
||||
"configKeys": "설정 키",
|
||||
"enable": "채널 활성화",
|
||||
"disable": "채널 비활성화",
|
||||
"needsConfig": "설정 필요",
|
||||
"connect": "연결",
|
||||
"reconnect": "다시 연결",
|
||||
"feishuQrAlt": "Feishu 연결 QR 코드",
|
||||
"feishuScanTitle": "Feishu로 스캔",
|
||||
"feishuScanDescription": "휴대폰의 Feishu 또는 Lark로 이 코드를 스캔하세요. 승인 후 nanobot이 자동으로 설정을 완료합니다.",
|
||||
"feishuWaiting": "승인을 기다리는 중...",
|
||||
"feishuConnected": "Feishu가 연결되었습니다.",
|
||||
"feishuConnectStopped": "연결이 중지되었습니다.",
|
||||
"feishuConnecting": "연결 중..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "활성화됨",
|
||||
"enable": "활성화",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "Hình ảnh",
|
||||
"voice": "Giọng nói",
|
||||
"browser": "Trang web",
|
||||
"channels": "Kênh",
|
||||
"runtime": "Hệ thống",
|
||||
"advanced": "Bảo mật",
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "Không có",
|
||||
"testForTools": "Chạy Kiểm tra để xem và chọn từng công cụ."
|
||||
},
|
||||
"api": {
|
||||
"title": "Máy chủ API", "openaiCompatible": "API tương thích OpenAI", "description": "Kết nối SDK và agent qua endpoint /v1 cục bộ.",
|
||||
"start": "Khởi động API", "starting": "Đang khởi động...", "stop": "Dừng", "stopping": "Đang dừng...",
|
||||
"access": "Truy cập", "thisDevice": "Thiết bị này", "localNetwork": "Mạng nội bộ",
|
||||
"localHelp": "Chỉ thiết bị này có thể kết nối.", "networkHelp": "Thiết bị khác có thể kết nối; cần khóa API.",
|
||||
"port": "Cổng", "portHelp": "API sử dụng cổng cục bộ này.", "apiKey": "Khóa API", "apiKeyHelp": "Client gửi khóa dưới dạng Bearer token.",
|
||||
"apiKeyRequired": "Bắt buộc trước khi mở API ra mạng.", "apiKeyPlaceholder": "Nhập khóa API",
|
||||
"autoInstall": "Hỗ trợ API sẽ tự động được cài khi khởi động."
|
||||
},
|
||||
"observability": {
|
||||
"title": "Khả năng quan sát", "configured": "Thông tin xác thực tracing đã sẵn sàng cho nanobot.",
|
||||
"environment": "Đặt LANGFUSE_SECRET_KEY và LANGFUSE_PUBLIC_KEY rồi khởi động lại nanobot.", "enable": "Bật hỗ trợ tracing"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Bật plugin, bộ chuyển đổi ứng dụng cục bộ và máy chủ công cụ đã kết nối.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||
"cliLabel": "Ứng dụng",
|
||||
"mcpLabel": "Tích hợp",
|
||||
"channelLabel": "Kênh",
|
||||
"featureLabel": "Tính năng",
|
||||
"filterAll": "Tất cả",
|
||||
"filterAll": "Sẵn sàng",
|
||||
"filterPlugins": "Plugin",
|
||||
"filterCli": "Ứng dụng CLI",
|
||||
"filterMcp": "Dịch vụ MCP",
|
||||
"enabledSummary": "{{count}} đã bật",
|
||||
"caption": "{{plugins}} plugin · {{cli}} CLI · {{mcp}} MCP",
|
||||
"filterCli": "Ứng dụng",
|
||||
"filterMcp": "Tích hợp",
|
||||
"enabledSummary": "{{count}} sẵn sàng",
|
||||
"caption": "{{cli}} ứng dụng · {{mcp}} tích hợp",
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Danh mục",
|
||||
"featured": "Công cụ",
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có ứng dụng phù hợp.",
|
||||
"empty": "Không có công cụ phù hợp với chế độ xem này.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng ứng dụng và tính năng đã cập nhật."
|
||||
},
|
||||
"channels": {
|
||||
"description": "Kết nối nanobot với các ứng dụng chat. Cài đặt hỗ trợ chỉ thêm gói tích hợp; hầu hết kênh vẫn cần token hoặc cấu hình workspace.",
|
||||
"caption": "{{enabled}} đã bật · {{total}} kênh",
|
||||
"searchPlaceholder": "Tìm kênh",
|
||||
"backToChannels": "Tất cả kênh",
|
||||
"catalog": "Kênh",
|
||||
"loading": "Đang tải kênh...",
|
||||
"empty": "Không có kênh nào phù hợp.",
|
||||
"restartRequired": "Khởi động lại nanobot để áp dụng hỗ trợ kênh đã cập nhật.",
|
||||
"requires": "Yêu cầu: {{requirements}}",
|
||||
"setUp": "Thiết lập",
|
||||
"setupGuide": "Hướng dẫn thiết lập",
|
||||
"setupSummary": "Bật chỉ kích hoạt hỗ trợ của nanobot. Thêm thông tin xác thực nền tảng rồi khởi động lại nanobot.",
|
||||
"configKeys": "Khóa cấu hình",
|
||||
"enable": "Bật kênh",
|
||||
"disable": "Tắt kênh",
|
||||
"needsConfig": "Cần cấu hình",
|
||||
"connect": "Kết nối",
|
||||
"reconnect": "Kết nối lại",
|
||||
"feishuQrAlt": "Mã QR kết nối Feishu",
|
||||
"feishuScanTitle": "Quét bằng Feishu",
|
||||
"feishuScanDescription": "Dùng Feishu hoặc Lark trên điện thoại để quét mã này. nanobot sẽ tự hoàn tất cấu hình sau khi cấp quyền.",
|
||||
"feishuWaiting": "Đang chờ cấp quyền...",
|
||||
"feishuConnected": "Feishu đã kết nối.",
|
||||
"feishuConnectStopped": "Kết nối đã dừng.",
|
||||
"feishuConnecting": "Đang kết nối..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "Đã bật",
|
||||
"enable": "Bật",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "图片",
|
||||
"voice": "语音",
|
||||
"browser": "网页",
|
||||
"channels": "渠道",
|
||||
"cliApps": "CLI 应用",
|
||||
"mcp": "MCP",
|
||||
"runtime": "系统",
|
||||
@@ -236,21 +237,21 @@
|
||||
"searchPlaceholder": "搜索 CLI",
|
||||
"loading": "正在加载 CLI 应用...",
|
||||
"empty": "没有匹配的 CLI 应用。",
|
||||
"statusInstalled": "CLI 已安装",
|
||||
"statusInstalled": "应用已就绪",
|
||||
"statusMissing": "缺失",
|
||||
"statusAvailable": "可用",
|
||||
"statusUnsupported": "暂不支持",
|
||||
"statusNotInstalled": "CLI 未安装",
|
||||
"statusNotInstalled": "应用未安装",
|
||||
"requires": "依赖",
|
||||
"test": "测试 CLI",
|
||||
"update": "更新 CLI",
|
||||
"uninstall": "卸载 CLI",
|
||||
"install": "安装 CLI",
|
||||
"test": "测试应用",
|
||||
"update": "更新应用",
|
||||
"uninstall": "卸载应用",
|
||||
"install": "安装应用",
|
||||
"readyTitle": "@{{name}} 已就绪",
|
||||
"readyStatus": "就绪",
|
||||
"readyTry": "试试 @{{name}}",
|
||||
"readyCopied": "已复制",
|
||||
"readyPrompt": "用 @{{name}} 看看这个 CLI 能做什么。",
|
||||
"readyPrompt": "让 nanobot 在这个任务中使用 @{{name}}。",
|
||||
"openChat": "回到对话",
|
||||
"unsupported": "暂不支持",
|
||||
"unavailable": "不可用",
|
||||
@@ -270,8 +271,8 @@
|
||||
"filterInstalled": "已启用",
|
||||
"filterNotInstalled": "未启用",
|
||||
"searchPlaceholder": "搜索 MCP 预设",
|
||||
"moreOptions": "更多 MCP 选项",
|
||||
"moreOptionsSubtitle": "添加自定义服务,或导入 mcp.json。",
|
||||
"moreOptions": "添加集成",
|
||||
"moreOptionsSubtitle": "连接自定义工具服务,或导入已有配置。",
|
||||
"customTitle": "自定义 MCP",
|
||||
"customSubtitle": "添加任意 stdio、HTTP 或 SSE MCP 服务。",
|
||||
"customAction": "自定义",
|
||||
@@ -462,23 +463,77 @@
|
||||
"configureProvider": "配置提供商",
|
||||
"missingCredential": "启用图片生成前请先配置此提供商。"
|
||||
},
|
||||
"api": {
|
||||
"title": "API 服务",
|
||||
"openaiCompatible": "OpenAI 兼容 API",
|
||||
"description": "让 SDK 和其他 Agent 通过本地 /v1 接口连接 nanobot。",
|
||||
"start": "启动 API 服务",
|
||||
"starting": "正在启动...",
|
||||
"stop": "停止",
|
||||
"stopping": "正在停止...",
|
||||
"access": "访问范围",
|
||||
"thisDevice": "仅此设备",
|
||||
"localNetwork": "局域网",
|
||||
"localHelp": "只有当前设备可以连接。",
|
||||
"networkHelp": "局域网内其他设备可以连接,因此必须设置 API Key。",
|
||||
"port": "端口",
|
||||
"portHelp": "API 服务使用的本地端口。",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyHelp": "客户端使用 Bearer Token 发送此密钥。",
|
||||
"apiKeyRequired": "向局域网开放 API 前必须设置密钥。",
|
||||
"apiKeyPlaceholder": "输入 API Key",
|
||||
"autoInstall": "启动时会自动安装 API 支持。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可观测性",
|
||||
"configured": "nanobot 已检测到追踪凭证。",
|
||||
"environment": "设置 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 后重启 nanobot。",
|
||||
"enable": "启用追踪支持"
|
||||
},
|
||||
"apps": {
|
||||
"description": "启用插件、本地应用适配器和已连接的工具服务。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "把工具连接到 nanobot,然后在对话中 @ 使用。",
|
||||
"cliLabel": "应用",
|
||||
"mcpLabel": "集成",
|
||||
"channelLabel": "渠道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "全部",
|
||||
"filterAll": "可用",
|
||||
"filterPlugins": "插件",
|
||||
"filterCli": "CLI 应用",
|
||||
"filterMcp": "MCP 服务",
|
||||
"enabledSummary": "已启用 {{count}} 个",
|
||||
"caption": "{{plugins}} 个插件 · {{cli}} 个 CLI · {{mcp}} 个 MCP",
|
||||
"searchPlaceholder": "搜索应用",
|
||||
"featured": "应用目录",
|
||||
"filterCli": "应用",
|
||||
"filterMcp": "集成",
|
||||
"enabledSummary": "{{count}} 个可用",
|
||||
"caption": "{{cli}} 个应用 · {{mcp}} 个集成",
|
||||
"searchPlaceholder": "搜索工具",
|
||||
"featured": "工具",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有匹配的应用。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和能力。"
|
||||
"empty": "当前视图没有匹配的工具。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的应用和集成。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "把聊天应用、邮箱和 WebUI 连接到 nanobot。",
|
||||
"caption": "{{enabled}} 个已启用 · 共 {{total}} 个渠道",
|
||||
"searchPlaceholder": "搜索渠道",
|
||||
"backToChannels": "所有渠道",
|
||||
"catalog": "渠道",
|
||||
"loading": "正在加载渠道...",
|
||||
"empty": "没有匹配的渠道。",
|
||||
"restartRequired": "重启 nanobot 以应用更新后的渠道支持。",
|
||||
"requires": "需要:{{requirements}}",
|
||||
"setUp": "设置",
|
||||
"setupGuide": "配置指南",
|
||||
"setupSummary": "启用只会打开 nanobot 的渠道支持。请补充平台凭据,然后重启 nanobot。",
|
||||
"configKeys": "配置字段",
|
||||
"enable": "启用渠道",
|
||||
"disable": "禁用渠道",
|
||||
"needsConfig": "需要配置",
|
||||
"connect": "连接",
|
||||
"reconnect": "重新连接",
|
||||
"feishuQrAlt": "飞书连接二维码",
|
||||
"feishuScanTitle": "使用飞书扫码",
|
||||
"feishuScanDescription": "用手机上的飞书或 Lark 扫描二维码。授权完成后,nanobot 会自动完成配置。",
|
||||
"feishuWaiting": "正在等待授权...",
|
||||
"feishuConnected": "飞书已连接。",
|
||||
"feishuConnectStopped": "连接已停止。",
|
||||
"feishuConnecting": "正在连接..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "已启用",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"image": "圖片",
|
||||
"voice": "語音",
|
||||
"browser": "網頁",
|
||||
"channels": "渠道",
|
||||
"runtime": "系統",
|
||||
"advanced": "安全",
|
||||
"cliApps": "CLI 應用",
|
||||
@@ -459,27 +460,67 @@
|
||||
"noTools": "無",
|
||||
"testForTools": "執行測試以檢查並選擇個別工具。"
|
||||
},
|
||||
"api": {
|
||||
"title": "API 服務", "openaiCompatible": "OpenAI 相容 API", "description": "讓 SDK 和其他 Agent 透過本機 /v1 介面連接 nanobot。",
|
||||
"start": "啟動 API 服務", "starting": "正在啟動...", "stop": "停止", "stopping": "正在停止...",
|
||||
"access": "存取範圍", "thisDevice": "僅此裝置", "localNetwork": "區域網路",
|
||||
"localHelp": "只有目前裝置可以連接。", "networkHelp": "區域網路內其他裝置可以連接,因此必須設定 API Key。",
|
||||
"port": "連接埠", "portHelp": "API 服務使用的本機連接埠。", "apiKey": "API Key", "apiKeyHelp": "用戶端使用 Bearer Token 傳送此金鑰。",
|
||||
"apiKeyRequired": "向區域網路開放 API 前必須設定金鑰。", "apiKeyPlaceholder": "輸入 API Key",
|
||||
"autoInstall": "啟動時會自動安裝 API 支援。"
|
||||
},
|
||||
"observability": {
|
||||
"title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。",
|
||||
"environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援"
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "啟用插件、本機應用適配器和已連接的工具服務。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"description": "把工具連接到 nanobot,然後在對話中使用 @。",
|
||||
"cliLabel": "應用",
|
||||
"mcpLabel": "整合",
|
||||
"channelLabel": "通道",
|
||||
"featureLabel": "能力",
|
||||
"filterAll": "全部",
|
||||
"filterAll": "可用",
|
||||
"filterPlugins": "插件",
|
||||
"filterCli": "CLI 應用",
|
||||
"filterMcp": "MCP 服務",
|
||||
"enabledSummary": "已啟用 {{count}} 個",
|
||||
"caption": "{{plugins}} 個插件 · {{cli}} 個 CLI · {{mcp}} 個 MCP",
|
||||
"filterCli": "應用",
|
||||
"filterMcp": "整合",
|
||||
"enabledSummary": "{{count}} 個可用",
|
||||
"caption": "{{cli}} 個應用 · {{mcp}} 個整合",
|
||||
"searchPlaceholder": "搜尋應用",
|
||||
"featured": "應用目錄",
|
||||
"featured": "工具",
|
||||
"loading": "正在載入應用...",
|
||||
"empty": "沒有符合的應用。",
|
||||
"empty": "目前檢視沒有符合的工具。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的應用和能力。"
|
||||
},
|
||||
"channels": {
|
||||
"description": "把聊天應用、郵箱和 WebUI 連接到 nanobot。",
|
||||
"caption": "{{enabled}} 個已啟用 · 共 {{total}} 個渠道",
|
||||
"searchPlaceholder": "搜尋渠道",
|
||||
"backToChannels": "所有渠道",
|
||||
"catalog": "渠道",
|
||||
"loading": "正在載入渠道...",
|
||||
"empty": "沒有符合的渠道。",
|
||||
"restartRequired": "重新啟動 nanobot 以套用更新後的渠道支援。",
|
||||
"requires": "需要:{{requirements}}",
|
||||
"setUp": "設定",
|
||||
"setupGuide": "設定指南",
|
||||
"setupSummary": "啟用只會打開 nanobot 的渠道支援。請補充平台憑證,然後重新啟動 nanobot。",
|
||||
"configKeys": "設定欄位",
|
||||
"enable": "啟用渠道",
|
||||
"disable": "停用渠道",
|
||||
"needsConfig": "需要設定",
|
||||
"connect": "連接",
|
||||
"reconnect": "重新連接",
|
||||
"feishuQrAlt": "飛書連接二維碼",
|
||||
"feishuScanTitle": "使用飛書掃碼",
|
||||
"feishuScanDescription": "用手機上的飛書或 Lark 掃描二維碼。授權完成後,nanobot 會自動完成設定。",
|
||||
"feishuWaiting": "正在等待授權...",
|
||||
"feishuConnected": "飛書已連接。",
|
||||
"feishuConnectStopped": "連接已停止。",
|
||||
"feishuConnecting": "正在連接..."
|
||||
},
|
||||
"nanobotFeatures": {
|
||||
"enabled": "已啟用",
|
||||
"enable": "啟用",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type {
|
||||
ApiServicePayload,
|
||||
AutomationsPayload,
|
||||
AutomationUpdatePayload,
|
||||
ChannelConfigurePayload,
|
||||
ChannelConnectPayload,
|
||||
ChannelValidationPayload,
|
||||
ChatSummary,
|
||||
CliAppsPayload,
|
||||
FilePreviewPayload,
|
||||
@@ -10,6 +14,7 @@ import type {
|
||||
ModelConfigurationCreate,
|
||||
ModelConfigurationUpdate,
|
||||
NetworkSafetySettingsUpdate,
|
||||
PairingPayload,
|
||||
ProviderModelsPayload,
|
||||
ProviderSettingsUpdate,
|
||||
SessionDeleteResult,
|
||||
@@ -44,6 +49,8 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
||||
&& SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle)
|
||||
);
|
||||
}
|
||||
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
||||
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
@@ -386,13 +393,43 @@ export async function fetchNanobotFeatures(
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
||||
return request<ApiServicePayload>(`${base}/api/settings/api-service`, token);
|
||||
}
|
||||
|
||||
export async function startApiService(
|
||||
token: string,
|
||||
values: { host: string; port: number; timeout: number; apiKey?: string },
|
||||
base: string = "",
|
||||
): Promise<ApiServicePayload> {
|
||||
const query = new URLSearchParams({
|
||||
host: values.host,
|
||||
port: String(values.port),
|
||||
timeout: String(values.timeout),
|
||||
});
|
||||
const headers = values.apiKey === undefined
|
||||
? undefined
|
||||
: { [API_SERVICE_VALUES_HEADER]: JSON.stringify({ api_key: values.apiKey }) };
|
||||
return request<ApiServicePayload>(
|
||||
`${base}/api/settings/api-service/start?${query}`,
|
||||
token,
|
||||
{ headers },
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopApiService(token: string, base: string = ""): Promise<ApiServicePayload> {
|
||||
return request<ApiServicePayload>(`${base}/api/settings/api-service/stop`, token);
|
||||
}
|
||||
|
||||
export async function enableNanobotFeature(
|
||||
token: string,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/enable?${query}`,
|
||||
token,
|
||||
@@ -402,16 +439,138 @@ export async function enableNanobotFeature(
|
||||
export async function disableNanobotFeature(
|
||||
token: string,
|
||||
name: string,
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<NanobotFeaturesPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<NanobotFeaturesPayload>(
|
||||
`${base}/api/settings/nanobot-features/disable?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPairingRequests(
|
||||
token: string,
|
||||
base: string = "",
|
||||
): Promise<PairingPayload> {
|
||||
return request<PairingPayload>(
|
||||
`${base}/api/settings/pairing`,
|
||||
token,
|
||||
undefined,
|
||||
API_READ_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPairingAction(
|
||||
token: string,
|
||||
action: "approve" | "deny",
|
||||
code: string,
|
||||
base: string = "",
|
||||
): Promise<PairingPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("code", code);
|
||||
return request<PairingPayload>(
|
||||
`${base}/api/settings/pairing/${action}?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function startChannelConnect(
|
||||
token: string,
|
||||
channel: "feishu" | "weixin",
|
||||
options: {
|
||||
domain?: "feishu" | "lark";
|
||||
instanceId?: string;
|
||||
mode?: "replace" | "create";
|
||||
force?: boolean;
|
||||
} = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
if (options.domain) query.set("domain", options.domain);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
if (options.mode) query.set("mode", options.mode);
|
||||
if (options.force) query.set("force", "true");
|
||||
const suffix = query.toString();
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/start${suffix ? `?${suffix}` : ""}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pollChannelConnect(
|
||||
token: string,
|
||||
channel: "feishu" | "weixin",
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/poll?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelChannelConnect(
|
||||
token: string,
|
||||
channel: "feishu" | "weixin",
|
||||
sessionId: string,
|
||||
base: string = "",
|
||||
): Promise<ChannelConnectPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("session_id", sessionId);
|
||||
return request<ChannelConnectPayload>(
|
||||
`${base}/api/settings/channels/${channel}/connect/cancel?${query}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export async function configureChannel(
|
||||
token: string,
|
||||
name: string,
|
||||
values: Record<string, string>,
|
||||
options: { enable?: boolean; instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelConfigurePayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.enable !== undefined) query.set("enable", String(options.enable));
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelConfigurePayload>(
|
||||
`${base}/api/settings/channels/configure?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateChannel(
|
||||
token: string,
|
||||
name: string,
|
||||
values: Record<string, string> = {},
|
||||
options: { instanceId?: string } = {},
|
||||
base: string = "",
|
||||
): Promise<ChannelValidationPayload> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("name", name);
|
||||
if (options.instanceId) query.set("instance_id", options.instanceId);
|
||||
return request<ChannelValidationPayload>(
|
||||
`${base}/api/settings/channels/validate?${query}`,
|
||||
token,
|
||||
{
|
||||
headers: {
|
||||
[CHANNEL_VALUES_HEADER]: JSON.stringify(values),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCliAppAction(
|
||||
token: string,
|
||||
action: "install" | "update" | "uninstall" | "test",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export function isLoopbackHost(host: string): boolean {
|
||||
let normalized = host.trim().toLowerCase();
|
||||
if (normalized.endsWith(".")) normalized = normalized.slice(0, -1);
|
||||
if (normalized.startsWith("[") && normalized.endsWith("]")) {
|
||||
normalized = normalized.slice(1, -1);
|
||||
}
|
||||
if (normalized === "localhost" || normalized === "::1") return true;
|
||||
|
||||
const octets = normalized.split(".");
|
||||
return (
|
||||
octets.length === 4 &&
|
||||
octets[0] === "127" &&
|
||||
octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
|
||||
);
|
||||
}
|
||||
+132
-1
@@ -332,6 +332,7 @@ export interface RuntimeCapabilities {
|
||||
export interface ProviderModelInfo {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
owned_by?: string | null;
|
||||
context_window?: number | null;
|
||||
}
|
||||
@@ -345,7 +346,7 @@ export interface ProviderModelsPayload {
|
||||
| "not_configured"
|
||||
| "missing_api_base"
|
||||
| "error";
|
||||
catalog_kind: "official" | "catalog" | "local" | "custom" | "unsupported";
|
||||
catalog_kind: "builtin" | "official" | "catalog" | "local" | "custom" | "unsupported";
|
||||
models: ProviderModelInfo[];
|
||||
model_count: number;
|
||||
message?: string | null;
|
||||
@@ -399,6 +400,7 @@ export interface SettingsPayload {
|
||||
api_base?: string | null;
|
||||
default_api_base?: string | null;
|
||||
model_selectable?: boolean;
|
||||
model_catalog?: ProviderModelsPayload["catalog_kind"];
|
||||
api_type?: "auto" | "chat_completions" | "responses";
|
||||
oauth_account?: string | null;
|
||||
oauth_expires_at?: number | null;
|
||||
@@ -428,6 +430,17 @@ export interface SettingsPayload {
|
||||
use_jina_reader: boolean;
|
||||
};
|
||||
};
|
||||
api?: {
|
||||
host: string;
|
||||
port: number;
|
||||
timeout: number;
|
||||
api_key_hint?: string | null;
|
||||
};
|
||||
observability?: {
|
||||
provider: "langfuse" | string;
|
||||
configured: boolean;
|
||||
base_url: string;
|
||||
};
|
||||
image_generation: {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
@@ -543,6 +556,26 @@ export interface SettingsPayload {
|
||||
version?: {
|
||||
current: string;
|
||||
};
|
||||
docs?: {
|
||||
version: string;
|
||||
base_url: string;
|
||||
chat_apps_url: string;
|
||||
latest_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiServicePayload {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
managed: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
timeout: number;
|
||||
api_key_hint?: string | null;
|
||||
endpoint: string;
|
||||
command: string;
|
||||
log_path?: string | null;
|
||||
last_action?: "started" | "stopped" | string;
|
||||
}
|
||||
|
||||
export interface AppPackageRef {
|
||||
@@ -638,6 +671,10 @@ export interface NanobotFeatureInfo {
|
||||
display_name: string;
|
||||
type: "channel" | "feature" | string;
|
||||
enabled: boolean;
|
||||
configured?: boolean;
|
||||
config_values?: Record<string, string>;
|
||||
configured_fields?: string[];
|
||||
instances?: NanobotChannelInstanceInfo[];
|
||||
installed: boolean;
|
||||
ready: boolean;
|
||||
status: "enabled" | "missing_dependency" | "not_enabled" | string;
|
||||
@@ -645,6 +682,19 @@ export interface NanobotFeatureInfo {
|
||||
requires_restart: boolean;
|
||||
}
|
||||
|
||||
export interface NanobotChannelInstanceInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
domain?: "feishu" | "lark" | string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
app_id?: string;
|
||||
group_policy?: string;
|
||||
allow_from?: string[];
|
||||
}
|
||||
|
||||
export interface NanobotFeaturesPayload {
|
||||
features: NanobotFeatureInfo[];
|
||||
enabled_count: number;
|
||||
@@ -656,6 +706,64 @@ export interface NanobotFeaturesPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export type ChannelSetupStatus =
|
||||
| "connected"
|
||||
| "configured"
|
||||
| "needs_setup"
|
||||
| "invalid"
|
||||
| "unsupported"
|
||||
| string;
|
||||
|
||||
export type ChannelValidationCheckStatus = "pass" | "warn" | "fail" | "skipped" | string;
|
||||
|
||||
export interface ChannelValidationCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
status: ChannelValidationCheckStatus;
|
||||
message?: string;
|
||||
action_url?: string;
|
||||
}
|
||||
|
||||
export interface ChannelIdentity {
|
||||
name?: string;
|
||||
workspace?: string;
|
||||
account?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface ChannelValidationPayload {
|
||||
name: string;
|
||||
status: ChannelSetupStatus;
|
||||
checks: ChannelValidationCheck[];
|
||||
identity?: ChannelIdentity;
|
||||
missing_fields: string[];
|
||||
can_enable: boolean;
|
||||
requires_restart: boolean;
|
||||
checked_at?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PairingRequestInfo {
|
||||
code: string;
|
||||
channel: string;
|
||||
sender_id: string;
|
||||
created_at_ms?: number | null;
|
||||
expires_at_ms?: number | null;
|
||||
expires_in_seconds?: number | null;
|
||||
}
|
||||
|
||||
export interface PairingPayload {
|
||||
requests: PairingRequestInfo[];
|
||||
last_action?: {
|
||||
ok: boolean;
|
||||
action: "approve" | "deny" | string;
|
||||
message: string;
|
||||
code?: string;
|
||||
channel?: string;
|
||||
sender_id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface McpPresetField {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -725,6 +833,29 @@ export interface McpPresetsPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export type ChannelConnectStatus = "pending" | "succeeded" | "expired" | "cancelled" | "failed";
|
||||
|
||||
export interface ChannelConnectPayload {
|
||||
session_id: string;
|
||||
instance_id?: string;
|
||||
status: ChannelConnectStatus;
|
||||
message?: string;
|
||||
qr_url?: string;
|
||||
domain?: string;
|
||||
interval_ms?: number;
|
||||
expires_at_ms?: number;
|
||||
app_id?: string;
|
||||
account?: string;
|
||||
nanobot_features?: NanobotFeaturesPayload;
|
||||
}
|
||||
|
||||
export interface ChannelConfigurePayload {
|
||||
name: string;
|
||||
saved: boolean;
|
||||
saved_keys?: string[];
|
||||
nanobot_features?: NanobotFeaturesPayload;
|
||||
}
|
||||
|
||||
export interface SettingsUpdate {
|
||||
model?: string;
|
||||
provider?: string;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
configureChannel,
|
||||
createModelConfiguration,
|
||||
deleteSession,
|
||||
fetchFilePreview,
|
||||
fetchAutomations,
|
||||
fetchApiService,
|
||||
fetchCliApps,
|
||||
fetchInstalledCliApps,
|
||||
fetchMcpPresets,
|
||||
@@ -28,6 +30,11 @@ import {
|
||||
runCliAppAction,
|
||||
runMcpPresetAction,
|
||||
saveCustomMcpServer,
|
||||
startApiService,
|
||||
stopApiService,
|
||||
cancelChannelConnect,
|
||||
pollChannelConnect,
|
||||
startChannelConnect,
|
||||
updateAutomation,
|
||||
updateSidebarState,
|
||||
updateImageGenerationSettings,
|
||||
@@ -37,6 +44,7 @@ import {
|
||||
updateProviderSettings,
|
||||
updateSettings,
|
||||
updateWebSearchSettings,
|
||||
validateChannel,
|
||||
} from "@/lib/api";
|
||||
|
||||
describe("webui API helpers", () => {
|
||||
@@ -116,6 +124,82 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("validates channel settings with form values", async () => {
|
||||
await validateChannel(
|
||||
"tok",
|
||||
"slack",
|
||||
{ "channels.slack.botToken": "xoxb-test" },
|
||||
{ instanceId: "default" },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/validate?name=slack&instance_id=default",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.slack.botToken": "xoxb-test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("configures channels through the WebSocket HTTP shim", async () => {
|
||||
await configureChannel(
|
||||
"tok",
|
||||
"discord",
|
||||
{ "channels.discord.token": "saved-secret" },
|
||||
{ enable: true },
|
||||
);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/channels/configure?name=discord&enable=true",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-Channel-Values": JSON.stringify({
|
||||
"channels.discord.token": "saved-secret",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes channel QR connect helpers", async () => {
|
||||
await startChannelConnect("tok", "weixin", { force: true });
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/start?force=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await pollChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/poll?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await cancelChannelConnect("tok", "weixin", "session+/=");
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
"/api/settings/channels/weixin/connect/cancel?session_id=session%2B%2F%3D",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes workspace automation actions", async () => {
|
||||
await runAutomationAction("tok", "disable", "job 1/2");
|
||||
|
||||
@@ -478,6 +562,44 @@ describe("webui API helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("manages the API service capability", async () => {
|
||||
await fetchApiService("tok");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
await startApiService("tok", { host: "127.0.0.1", port: 8900, timeout: 120 });
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=127.0.0.1&port=8900&timeout=120",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
|
||||
await startApiService(
|
||||
"tok",
|
||||
{ host: "0.0.0.0", port: 8900, timeout: 120, apiKey: "secret-token" },
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/start?host=0.0.0.0&port=8900&timeout=120",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-API-Service-Values": JSON.stringify({ api_key: "secret-token" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-token"),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
await stopApiService("tok");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/api-service/stop",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads MCP presets and serializes actions", async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -249,6 +249,8 @@ describe("App layout", () => {
|
||||
localStorage.removeItem("nanobot-webui.sidebar");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
|
||||
localStorage.removeItem("nanobot-webui.sidebar.session-updates.v1");
|
||||
localStorage.removeItem("nanobot-webui.restartStartedAt");
|
||||
localStorage.removeItem("nanobot-webui.restartRoute");
|
||||
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
|
||||
token: "tok",
|
||||
api_token: "api-tok",
|
||||
@@ -343,6 +345,35 @@ describe("App layout", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("restores the Settings route after a restart fallback hash", async () => {
|
||||
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
|
||||
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
|
||||
window.history.replaceState(null, "", "/#/new");
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
"/api/settings/nanobot-features": {
|
||||
features: [{
|
||||
name: "websocket",
|
||||
display_name: "Websocket",
|
||||
type: "channel",
|
||||
enabled: true,
|
||||
installed: true,
|
||||
ready: true,
|
||||
status: "enabled",
|
||||
install_supported: true,
|
||||
requires_restart: true,
|
||||
}],
|
||||
enabled_count: 1,
|
||||
},
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect((await screen.findAllByRole("heading", { name: "Channels" })).length).toBeGreaterThan(0);
|
||||
expect(window.location.hash).toBe("#/settings?section=channels");
|
||||
});
|
||||
|
||||
it("opens Skills from the main sidebar", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/settings": baseSettingsPayload(),
|
||||
@@ -1586,6 +1617,7 @@ describe("App layout", () => {
|
||||
expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Files" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
|
||||
@@ -1708,6 +1740,16 @@ describe("App layout", () => {
|
||||
expect(window.location.hash).toBe("#/settings?section=voice");
|
||||
});
|
||||
|
||||
it("falls back to Overview for the retired Files settings URL", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
window.history.replaceState(null, "", "/#/settings?section=files");
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("updates the URL hash when switching settings sections", async () => {
|
||||
mockFetchRoutes({ "/api/settings": baseSettingsPayload() });
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SLACK_SOCKET_MODE_MANIFEST } from "@/components/settings/channels/catalog";
|
||||
|
||||
describe("Slack setup manifest", () => {
|
||||
it.each(["app_mention", "message.channels", "message.groups", "message.im", "message.mpim"])(
|
||||
"subscribes to %s",
|
||||
(event) => expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${event}`),
|
||||
);
|
||||
|
||||
it.each([
|
||||
"app_mentions:read",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
"im:write",
|
||||
"mpim:history",
|
||||
"reactions:write",
|
||||
"users:read",
|
||||
])("requests the %s scope", (scope) => {
|
||||
expect(SLACK_SOCKET_MODE_MANIFEST).toContain(` - ${scope}`);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,6 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.sections.capabilities",
|
||||
"settings.sections.apps",
|
||||
"settings.apps.description",
|
||||
"settings.apps.filterPlugins",
|
||||
"settings.apps.caption",
|
||||
"settings.apps.restartRequired",
|
||||
"settings.nanobotFeatures.disable",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isLoopbackHost } from "@/lib/network";
|
||||
|
||||
describe("isLoopbackHost", () => {
|
||||
it.each(["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"])(
|
||||
"accepts explicit loopback host %s",
|
||||
(host) => expect(isLoopbackHost(host)).toBe(true),
|
||||
);
|
||||
|
||||
it.each(["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"])(
|
||||
"rejects network host %s",
|
||||
(host) => expect(isLoopbackHost(host)).toBe(false),
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
__clearLogoFallbackCacheForTests,
|
||||
useLogoFallback,
|
||||
} from "@/hooks/useLogoFallback";
|
||||
|
||||
function TestLogo({ urls }: { urls: string[] }) {
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(urls);
|
||||
if (!logoUrl) return <span>No logo</span>;
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Logo"
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useLogoFallback", () => {
|
||||
afterEach(() => {
|
||||
__clearLogoFallbackCacheForTests();
|
||||
});
|
||||
|
||||
it("remembers failed and loaded logo candidates across remounts", () => {
|
||||
const urls = [
|
||||
"https://bad.example/favicon.ico",
|
||||
"https://good.example/favicon.ico",
|
||||
];
|
||||
const first = render(<TestLogo urls={urls} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[0]);
|
||||
|
||||
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
|
||||
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
|
||||
|
||||
fireEvent.load(screen.getByRole("img", { name: "Logo" }));
|
||||
first.unmount();
|
||||
render(<TestLogo urls={urls} />);
|
||||
|
||||
expect(screen.getByRole("img", { name: "Logo" })).toHaveAttribute("src", urls[1]);
|
||||
});
|
||||
|
||||
it("returns no logo once every candidate failed", () => {
|
||||
const urls = ["https://bad.example/favicon.ico"];
|
||||
render(<TestLogo urls={urls} />);
|
||||
|
||||
fireEvent.error(screen.getByRole("img", { name: "Logo" }));
|
||||
|
||||
expect(screen.getByText("No logo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user