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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user