feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

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

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+20 -47
View File
@@ -211,17 +211,6 @@ def _builtin_skill_read_path(path: str) -> Path | None:
return candidate if candidate.is_file() else None
def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
"""Parse a page range like '2-5' into 0-based (start, end) inclusive."""
parts = pages.strip().split("-")
if len(parts) == 1:
p = int(parts[0])
return max(0, p - 1), min(p - 1, total - 1)
start = int(parts[0])
end = int(parts[1])
return max(0, start - 1), min(end - 1, total - 1)
@tool_parameters(
tool_parameters_schema(
path=StringSchema("The file path to read"),
@@ -405,49 +394,33 @@ class ReadFileTool(_FsTool):
return ToolResult.error(f"Error reading file: {e}")
def _read_pdf(self, fp: Path, pages: str | None) -> str:
try:
import fitz # pymupdf
except ImportError:
return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
from nanobot.utils.document import PdfPageRangeError, PdfSafetyError, extract_pdf_pages
try:
doc = fitz.open(str(fp))
extraction = extract_pdf_pages(
fp,
pages=pages,
max_pages=self._MAX_PDF_PAGES,
max_chars=self._MAX_CHARS,
)
except PdfPageRangeError:
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
except PdfSafetyError as e:
return ToolResult.error(f"Error reading PDF: {e}")
except Exception as e:
return ToolResult.error(f"Error reading PDF: {e}")
total_pages = len(doc)
if pages:
try:
start, end = _parse_page_range(pages, total_pages)
except (ValueError, IndexError):
doc.close()
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
if start > end or start >= total_pages:
doc.close()
return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
else:
start = 0
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
if end - start + 1 > self._MAX_PDF_PAGES:
end = start + self._MAX_PDF_PAGES - 1
parts: list[str] = []
for i in range(start, end + 1):
page = doc[i]
text = page.get_text().strip()
if text:
parts.append(f"--- Page {i + 1} ---\n{text}")
doc.close()
if not parts:
if not extraction.text:
return f"(PDF has no extractable text: {fp})"
result = "\n\n".join(parts)
if end < total_pages - 1:
result += f"\n\n(Showing pages {start + 1}-{end + 1} of {total_pages}. Use pages='{end + 2}-{min(end + 1 + self._MAX_PDF_PAGES, total_pages)}' to continue.)"
if len(result) > self._MAX_CHARS:
result = result[:self._MAX_CHARS] + "\n\n(PDF text truncated at ~128K chars)"
result = extraction.text
if extraction.end_page < extraction.total_pages - 1:
next_start = extraction.end_page + 2
next_end = min(extraction.end_page + 1 + self._MAX_PDF_PAGES, extraction.total_pages)
result += (
f"\n\n(Showing pages {extraction.start_page + 1}-{extraction.end_page + 1} "
f"of {extraction.total_pages}. Use pages='{next_start}-{next_end}' to continue.)"
)
return result
def _read_office_doc(self, fp: Path) -> str:
+60
View File
@@ -0,0 +1,60 @@
"""Background process control for the WebUI-managed OpenAI-compatible API."""
from __future__ import annotations
import hashlib
import sys
from dataclasses import dataclass
from pathlib import Path
from nanobot.process_runtime import (
ManagedProcessRuntime,
ProcessRuntimePaths,
ProcessStartOptions,
)
@dataclass(frozen=True)
class ApiStartOptions(ProcessStartOptions):
"""Options needed to start a managed ``nanobot serve`` process."""
host: str = "127.0.0.1"
def api_runtime_paths(config_path: Path) -> ProcessRuntimePaths:
"""Return isolated state and log paths for one API process."""
resolved = config_path.expanduser().resolve(strict=False)
suffix = hashlib.sha256(str(resolved).encode("utf-8")).hexdigest()[:16]
run_dir = resolved.parent / "run"
logs_dir = resolved.parent / "logs"
return ProcessRuntimePaths(
run_dir=run_dir,
logs_dir=logs_dir,
state_path=run_dir / f"api.{suffix}.json",
log_path=logs_dir / f"api.{suffix}.log",
)
class ApiRuntime(ManagedProcessRuntime):
"""Manage a WebUI-controlled OpenAI-compatible API process."""
service_name = "api"
def _build_child_command(self, options: ApiStartOptions) -> list[str]:
command = [
self.python_executable or sys.executable,
"-m",
"nanobot",
"serve",
"--host",
options.host,
"--port",
str(options.port),
]
if options.verbose:
command.append("--verbose")
if options.workspace:
command.extend(["--workspace", options.workspace])
if options.config_path:
command.extend(["--config", options.config_path])
return command
+184
View File
@@ -0,0 +1,184 @@
"""Helpers for channel instance configuration.
The first consumer is Feishu/Lark. Keep the helpers small and data-oriented so
ChannelManager can support Feishu assistant instances without turning every
channel into a multi-instance abstraction.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from loguru import logger
from nanobot.config.loader import merge_missing_defaults
DEFAULT_INSTANCE_ID = "default"
_INSTANCE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
@dataclass(frozen=True)
class ChannelInstanceSpec:
"""Runtime description for one channel instance."""
base_name: str
instance_id: str
runtime_name: str
config: dict[str, Any]
def validate_instance_id(value: str) -> str:
"""Return a normalized instance id or raise ValueError."""
instance_id = value.strip()
if not instance_id or not _INSTANCE_ID_RE.fullmatch(instance_id):
raise ValueError("instance id must match [A-Za-z0-9_-]+")
return instance_id
def runtime_channel_name(base_name: str, instance_id: str) -> str:
"""Return the channel key used for routing messages at runtime."""
return base_name if instance_id == DEFAULT_INSTANCE_ID else f"{base_name}.{instance_id}"
def _base_feishu_instance_config(defaults: dict[str, Any]) -> dict[str, Any]:
config = dict(defaults)
config["instanceId"] = DEFAULT_INSTANCE_ID
config["name"] = "nanobot"
return config
def _normalize_feishu_instance(
raw: dict[str, Any],
defaults: dict[str, Any],
*,
inherited: dict[str, Any] | None = None,
fallback_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
config = merge_missing_defaults(inherited or {}, defaults)
config = merge_missing_defaults(raw, config)
raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id
instance_id = validate_instance_id(str(raw_id))
config["id"] = instance_id
config["instanceId"] = instance_id
config.setdefault("name", "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}")
return config
def feishu_instance_specs(
section: Any,
defaults: dict[str, Any],
*,
enabled_only: bool = False,
) -> list[ChannelInstanceSpec]:
"""Expand legacy or canonical Feishu config into runtime instance specs."""
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
section = {}
instances = section.get("instances")
raw_specs: list[dict[str, Any]]
inherited: dict[str, Any] | None = None
if isinstance(instances, list):
inherited = {key: value for key, value in section.items() if key != "instances"}
raw_specs = [item for item in instances if isinstance(item, dict)]
else:
raw_specs = [section] if section else [_base_feishu_instance_config(defaults)]
specs: list[ChannelInstanceSpec] = []
for index, raw in enumerate(raw_specs):
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try:
config = _normalize_feishu_instance(
raw,
defaults,
inherited=inherited,
fallback_id=fallback_id,
)
except ValueError as exc:
logger.warning("Skipping invalid Feishu instance config: {}", exc)
continue
enabled = bool(config.get("enabled", defaults.get("enabled", False)))
if enabled_only and not enabled:
continue
instance_id = str(config["instanceId"])
specs.append(
ChannelInstanceSpec(
base_name="feishu",
instance_id=instance_id,
runtime_name=runtime_channel_name("feishu", instance_id),
config=config,
)
)
return specs
def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str, Any]:
"""Return Feishu config in the canonical ``instances`` shape."""
specs = feishu_instance_specs(section, defaults)
return {"instances": [dict(spec.config) for spec in specs]}
def upsert_feishu_instance(
section: Any,
defaults: dict[str, Any],
instance_id: str,
values: dict[str, Any],
) -> dict[str, Any]:
"""Return canonical Feishu section with one instance created or updated."""
instance_id = validate_instance_id(instance_id)
canonical = canonical_feishu_section(section, defaults)
instances = canonical.setdefault("instances", [])
for instance in instances:
if instance.get("id") == instance_id or instance.get("instanceId") == instance_id:
instance.update(values)
instance["id"] = instance_id
instance["instanceId"] = instance_id
instance.setdefault("name", "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}")
return canonical
config = _normalize_feishu_instance(
{**values, "id": instance_id},
defaults,
fallback_id=instance_id,
)
instances.append(config)
return canonical
def update_feishu_instance_preserving_shape(
section: Any,
defaults: dict[str, Any],
instance_id: str,
values: dict[str, Any],
) -> dict[str, Any]:
"""Update background metadata without migrating a legacy flat section."""
instance_id = validate_instance_id(instance_id)
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if (
instance_id == DEFAULT_INSTANCE_ID
and isinstance(section, dict)
and not isinstance(section.get("instances"), list)
):
return {**section, **values}
return upsert_feishu_instance(section, defaults, instance_id, values)
def set_feishu_instance_enabled(
section: Any,
defaults: dict[str, Any],
instance_id: str,
enabled: bool,
) -> dict[str, Any]:
"""Return canonical Feishu section with one instance's enabled flag updated."""
return upsert_feishu_instance(section, defaults, instance_id, {"enabled": enabled})
+126
View File
@@ -0,0 +1,126 @@
"""Shared Feishu/Lark WebSocket runtime.
The official lark_oapi websocket client stores an asyncio loop in a module-level
variable. Running one blocking ``Client.start()`` per assistant would make
multiple Feishu instances fragile, so this module centralizes the loop patch and
starts each client through the SDK's async primitives on one dedicated loop.
"""
from __future__ import annotations
import asyncio
import threading
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from loguru import logger
@dataclass
class _ClientRuntime:
client: Any
stop_event: asyncio.Event
task: asyncio.Task
class FeishuWsRunner:
"""Run multiple lark_oapi websocket clients on one dedicated event loop."""
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._ready = threading.Event()
self._lock = threading.Lock()
self._clients: dict[str, _ClientRuntime] = {}
async def start_client(self, key: str, client: Any) -> None:
"""Start or replace one client runtime."""
loop = self._ensure_loop()
await asyncio.wrap_future(
asyncio.run_coroutine_threadsafe(self._start_client(key, client), loop)
)
async def stop_client(self, key: str) -> None:
"""Stop one client runtime if it is active."""
loop = self._loop
if loop is None or loop.is_closed():
return
await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self._stop_client(key), loop))
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
with self._lock:
if self._loop is not None and not self._loop.is_closed():
return self._loop
self._ready.clear()
self._thread = threading.Thread(target=self._run_loop, name="feishu-ws", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=10) or self._loop is None:
raise RuntimeError("Feishu WebSocket runner did not start")
return self._loop
def _run_loop(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
import lark_oapi.ws.client as lark_ws_client
lark_ws_client.loop = loop
self._loop = loop
self._ready.set()
loop.run_forever()
finally:
with suppress(Exception):
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
async def _start_client(self, key: str, client: Any) -> None:
await self._stop_client(key)
stop_event = asyncio.Event()
task = asyncio.create_task(self._client_main(key, client, stop_event))
self._clients[key] = _ClientRuntime(client=client, stop_event=stop_event, task=task)
async def _stop_client(self, key: str) -> None:
runtime = self._clients.pop(key, None)
if runtime is None:
return
runtime.stop_event.set()
with suppress(Exception):
await runtime.client._disconnect()
runtime.task.cancel()
with suppress(asyncio.CancelledError):
await runtime.task
async def _client_main(self, key: str, client: Any, stop_event: asyncio.Event) -> None:
ping_task: asyncio.Task | None = None
while not stop_event.is_set():
try:
await client._connect()
ping_task = asyncio.create_task(client._ping_loop())
await stop_event.wait()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Feishu WebSocket client '{}' failed: {}", key, exc)
with suppress(Exception):
await client._disconnect()
if not stop_event.is_set():
await asyncio.sleep(5)
finally:
if ping_task is not None:
ping_task.cancel()
with suppress(asyncio.CancelledError):
await ping_task
with suppress(Exception):
await client._disconnect()
_RUNNER: FeishuWsRunner | None = None
def get_feishu_ws_runner() -> FeishuWsRunner:
"""Return the process-wide Feishu WebSocket runner."""
global _RUNNER
if _RUNNER is None:
_RUNNER = FeishuWsRunner()
return _RUNNER
+343
View File
@@ -0,0 +1,343 @@
"""Shared channel setup contract for configuration, display, and validation."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Literal
FieldKind = Literal["string", "secret", "list", "bool", "int", "enum"]
RouteFieldType = str | tuple[str, set[str]]
@dataclass(frozen=True)
class ChannelFieldSpec:
"""One channel field exposed through the settings contract."""
kind: FieldKind = "string"
choices: frozenset[str] = frozenset()
writable: bool = True
snapshot: bool = True
@property
def route_type(self) -> RouteFieldType:
if self.kind == "enum":
return ("enum", set(self.choices))
return self.kind
@dataclass(frozen=True)
class SetupRequirement:
"""A requirement satisfied by any one complete field group."""
alternatives: tuple[tuple[str, ...], ...]
def is_satisfied(self, values: Any) -> bool:
return any(
all(channel_value_present(channel_field_value(values, field)) for field in group)
for group in self.alternatives
)
@property
def simple_field(self) -> str | None:
if len(self.alternatives) == 1 and len(self.alternatives[0]) == 1:
return self.alternatives[0][0]
return None
@dataclass(frozen=True)
class ChannelSetupSpec:
"""Save, display, and validation contract for one channel."""
fields: dict[str, ChannelFieldSpec]
required: tuple[SetupRequirement, ...] = ()
official_url: str | None = None
@property
def secrets(self) -> frozenset[str]:
return frozenset(name for name, field in self.fields.items() if field.kind == "secret")
@property
def snapshot_fields(self) -> tuple[str, ...]:
return tuple(name for name, field in self.fields.items() if field.snapshot)
@property
def route_field_types(self) -> dict[str, RouteFieldType]:
return {
name: field.route_type
for name, field in self.fields.items()
if field.writable
}
@property
def simple_required_fields(self) -> tuple[str, ...]:
return tuple(
field
for requirement in self.required
if (field := requirement.simple_field) is not None
)
def is_configured(self, values: Any) -> bool:
return bool(self.required) and all(
requirement.is_satisfied(values) for requirement in self.required
)
def _field(
kind: FieldKind = "string",
*,
choices: set[str] | None = None,
writable: bool = True,
snapshot: bool = True,
) -> ChannelFieldSpec:
return ChannelFieldSpec(
kind=kind,
choices=frozenset(choices or ()),
writable=writable,
snapshot=snapshot,
)
def _required(field: str) -> SetupRequirement:
return SetupRequirement(((field,),))
def _one_of(*alternatives: tuple[str, ...]) -> SetupRequirement:
return SetupRequirement(alternatives)
_GROUP_POLICIES = {"mention", "open", "allowlist"}
_DIRECT_GROUP_POLICIES = {"mention", "open"}
CHANNEL_SETUP_SPECS: dict[str, ChannelSetupSpec] = {
"websocket": ChannelSetupSpec(
fields={},
official_url="http://127.0.0.1:8765",
),
"telegram": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list"),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
},
required=(_required("token"),),
official_url="https://t.me/BotFather",
),
"slack": ChannelSetupSpec(
fields={
"appToken": _field("secret"),
"botToken": _field("secret"),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
},
required=(_required("appToken"), _required("botToken")),
official_url="https://api.slack.com/apps",
),
"discord": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list", snapshot=False),
"allowChannels": _field("list"),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
},
required=(_required("token"),),
official_url="https://discord.com/developers/applications",
),
"email": ChannelSetupSpec(
fields={
"consentGranted": _field("bool"),
"imapHost": _field(),
"imapPort": _field("int"),
"imapUsername": _field(),
"imapPassword": _field("secret"),
"smtpHost": _field(),
"smtpPort": _field("int"),
"smtpUsername": _field(),
"smtpPassword": _field("secret"),
"fromAddress": _field(),
"pollIntervalSeconds": _field("int"),
"allowFrom": _field("list"),
"verifyDkim": _field("bool"),
"verifySpf": _field("bool"),
},
required=tuple(
_required(field)
for field in (
"consentGranted",
"imapHost",
"imapUsername",
"imapPassword",
"smtpHost",
"smtpUsername",
"smtpPassword",
)
),
official_url="https://support.google.com/accounts/answer/185833",
),
"matrix": ChannelSetupSpec(
fields={
"homeserver": _field(),
"userId": _field(),
"password": _field("secret"),
"accessToken": _field("secret"),
"deviceId": _field(),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
"allowFrom": _field("list", writable=False),
},
required=(
_required("homeserver"),
_required("userId"),
_one_of(("password",), ("accessToken", "deviceId")),
),
official_url="https://matrix.org/ecosystem/clients/",
),
"mattermost": ChannelSetupSpec(
fields={
"serverUrl": _field(),
"token": _field("secret"),
"teamId": _field(),
"groupPolicy": _field("enum", choices=_GROUP_POLICIES),
"allowFrom": _field("list"),
},
required=(_required("serverUrl"), _required("token")),
official_url="https://developers.mattermost.com/integrate/reference/bot-accounts/",
),
"whatsapp": ChannelSetupSpec(
fields={
"allowFrom": _field("list", snapshot=False),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False),
"databasePath": _field(writable=False, snapshot=False),
},
official_url="https://faq.whatsapp.com/",
),
"dingtalk": ChannelSetupSpec(
fields={
"clientId": _field(),
"clientSecret": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("clientId"), _required("clientSecret")),
official_url="https://open.dingtalk.com/",
),
"wecom": ChannelSetupSpec(
fields={
"botId": _field(),
"secret": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("botId"), _required("secret")),
official_url="https://developer.work.weixin.qq.com/",
),
"weixin": ChannelSetupSpec(
fields={
"token": _field("secret"),
"allowFrom": _field("list"),
},
required=(_required("token"),),
official_url="https://weixin.qq.com/",
),
"qq": ChannelSetupSpec(
fields={
"appId": _field(),
"secret": _field("secret"),
"allowFrom": _field("list"),
"msgFormat": _field("enum", choices={"plain", "markdown"}),
},
required=(_required("appId"), _required("secret")),
official_url="https://q.qq.com/",
),
"signal": ChannelSetupSpec(
fields={
"phoneNumber": _field(),
"daemonHost": _field(),
"daemonPort": _field("int"),
"allowFrom": _field("list", snapshot=False),
"dm.allowFrom": _field("list"),
"group.allowFrom": _field("list"),
},
required=(_required("phoneNumber"),),
official_url="https://github.com/bbernhard/signal-cli-rest-api",
),
"msteams": ChannelSetupSpec(
fields={
"appId": _field(),
"appPassword": _field("secret"),
"tenantId": _field(),
"path": _field(),
"allowFrom": _field("list"),
},
required=(_required("appId"), _required("appPassword")),
official_url="https://dev.teams.microsoft.com/apps",
),
"napcat": ChannelSetupSpec(
fields={
"wsUrl": _field(),
"accessToken": _field("secret"),
"allowFrom": _field("list"),
"groupPolicy": _field("enum", choices=_DIRECT_GROUP_POLICIES),
},
required=(_required("wsUrl"),),
official_url="https://napneko.github.io/",
),
"feishu": ChannelSetupSpec(
fields={
"appId": _field(snapshot=False),
"appSecret": _field("secret", snapshot=False),
"domain": _field("enum", choices={"feishu", "lark"}, snapshot=False),
"groupPolicy": _field(
"enum", choices=_DIRECT_GROUP_POLICIES, snapshot=False
),
"allowFrom": _field("list", snapshot=False),
"topicIsolation": _field("bool", snapshot=False),
},
required=(_required("appId"), _required("appSecret")),
official_url="https://open.feishu.cn/app",
),
}
def channel_setup_spec(name: str) -> ChannelSetupSpec | None:
return CHANNEL_SETUP_SPECS.get(name)
def channel_field_value(values: Any, field_path: str) -> Any:
current = values
for part in field_path.split("."):
candidates = (part, _camel_to_snake(part))
if isinstance(current, dict):
for candidate in candidates:
if candidate in current:
current = current[candidate]
break
else:
return None
continue
for candidate in candidates:
if hasattr(current, candidate):
current = getattr(current, candidate)
break
else:
return None
return current
def channel_value_present(value: Any) -> bool:
return value not in (None, "", [], {})
def stringify_channel_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, list):
return ", ".join(str(item) for item in value)
return str(value)
def _camel_to_snake(value: str) -> str:
chars: list[str] = []
for char in value:
if char.isupper():
if chars:
chars.append("_")
chars.append(char.lower())
else:
chars.append(char)
return "".join(chars)
+414 -69
View File
@@ -13,6 +13,7 @@ import uuid
from collections import OrderedDict
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field
@@ -24,10 +25,19 @@ from rich.text import Text
from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent
from nanobot.bus.queue import MessageBus
from nanobot.channels._feishu_instances import (
DEFAULT_INSTANCE_ID,
feishu_instance_specs,
runtime_channel_name,
update_feishu_instance_preserving_shape,
upsert_feishu_instance,
)
from nanobot.channels._feishu_ws import get_feishu_ws_runner
from nanobot.channels.base import BaseChannel
from nanobot.command.router import normalize_command_text
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.pairing import clear_channel
from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging
@@ -38,6 +48,10 @@ FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
_LOGIN_CONSOLE = Console()
def _identity_timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
def _load_lark_runtime() -> tuple[Any, str, str]:
"""Import the heavy Feishu SDK lazily.
@@ -68,6 +82,58 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
return lark, FEISHU_DOMAIN, LARK_DOMAIN
def fetch_feishu_app_identity(
app_id: str,
app_secret: str,
domain: str = "feishu",
) -> dict[str, str]:
"""Fetch the user-facing Feishu/Lark app identity for display.
This is best-effort metadata for WebUI presentation. Callers should treat
an empty result as a normal fallback path.
"""
if not FEISHU_AVAILABLE or not app_id or not app_secret:
return {}
try:
lark, feishu_domain, lark_domain = _load_lark_runtime()
from lark_oapi.api.application.v6.model.get_application_request import (
GetApplicationRequest,
)
sdk_domain = lark_domain if domain == "lark" else feishu_domain
client = (
lark.Client.builder()
.app_id(app_id)
.app_secret(app_secret)
.domain(sdk_domain)
.timeout(5)
.build()
)
request = GetApplicationRequest.builder().app_id(app_id).lang("zh_cn").build()
response = client.application.v6.application.get(request)
if hasattr(response, "success") and not response.success():
return {}
app = getattr(getattr(response, "data", None), "app", None)
if app is None:
return {}
identity: dict[str, str] = {}
display_name = str(getattr(app, "app_name", "") or "").strip()
avatar_url = str(getattr(app, "avatar_url", "") or "").strip()
if display_name:
identity["displayName"] = display_name
if avatar_url:
identity["avatarUrl"] = avatar_url
if identity:
identity["identityFetchedAt"] = _identity_timestamp()
return identity
except Exception:
return {}
# Message type display mapping
MSG_TYPE_MAP = {
"image": "[image]",
@@ -343,6 +409,9 @@ def _extract_post_text(content_json: dict) -> str:
class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection."""
instance_id: str = DEFAULT_INSTANCE_ID
name: str = "nanobot"
identity_key: str = ""
enabled: bool = False
app_id: str = ""
app_secret: str = ""
@@ -448,39 +517,24 @@ def _poll_registration(
"""
deadline = time.monotonic() + expire_in
current_domain = domain
poll_count = 0
while time.monotonic() < deadline:
base_url = _accounts_base_url(current_domain)
try:
res = _post_registration(base_url, {
"action": "poll",
"device_code": device_code,
"tp": "ob_app",
})
res = poll_registration_once(device_code=device_code, domain=current_domain)
except Exception:
time.sleep(interval)
continue
poll_count += 1
current_domain = res.get("domain", current_domain)
# Domain auto-detection: if the user's tenant is on Lark, switch automatically
user_info = res.get("user_info") or {}
tenant_brand = user_info.get("tenant_brand")
if tenant_brand == "lark":
current_domain = "lark"
# Success
if res.get("client_id") and res.get("client_secret"):
if res.get("status") == "succeeded":
return {
"app_id": res["client_id"],
"app_secret": res["client_secret"],
"domain": current_domain,
"app_id": res["app_id"],
"app_secret": res["app_secret"],
"domain": res.get("domain", current_domain),
}
# Terminal errors
error = res.get("error", "")
if error in ("access_denied", "expired_token"):
if res.get("status") == "failed":
_LOGIN_CONSOLE.print("[yellow]Authorization was cancelled or expired.[/yellow]")
return None
@@ -491,6 +545,230 @@ def _poll_registration(
return None
def poll_registration_once(
*,
device_code: str,
domain: str = "feishu",
) -> dict:
"""Poll the Feishu/Lark device-code flow once.
This non-blocking shape is used by WebUI. The CLI keeps using
``_poll_registration`` to wait in the terminal.
"""
current_domain = domain
base_url = _accounts_base_url(current_domain)
res = _post_registration(base_url, {
"action": "poll",
"device_code": device_code,
"tp": "ob_app",
})
user_info = res.get("user_info") or {}
tenant_brand = user_info.get("tenant_brand")
if tenant_brand == "lark":
current_domain = "lark"
if res.get("client_id") and res.get("client_secret"):
return {
"status": "succeeded",
"app_id": res["client_id"],
"app_secret": res["client_secret"],
"domain": current_domain,
}
error = res.get("error", "")
if error in ("access_denied", "expired_token"):
return {
"status": "failed",
"error": error,
"domain": current_domain,
}
return {
"status": "pending",
"domain": current_domain,
}
def _feishu_app_identity_key(app_id: str, domain: str) -> str:
normalized_app_id = app_id.strip()
normalized_domain = "lark" if domain.strip().lower() == "lark" else "feishu"
return f"{normalized_domain}:{normalized_app_id}" if normalized_app_id else ""
def _saved_feishu_instance_identity_key(
feishu_cfg: Any,
defaults: dict[str, Any],
instance_id: str,
) -> str:
for spec in feishu_instance_specs(feishu_cfg, defaults):
if spec.instance_id == instance_id:
return _feishu_app_identity_key(
str(spec.config.get("appId") or spec.config.get("app_id") or ""),
str(spec.config.get("domain") or "feishu"),
)
return ""
def sync_saved_feishu_identity_boundary(
*,
instance_id: str,
app_id: str,
domain: str,
) -> bool:
"""Persist the Feishu app identity marker and clear access if it changed.
WebUI connect normally handles this at save time. This startup check catches
manual config edits so approved users do not accidentally carry over to a
different Feishu/Lark app in the same local instance slot.
"""
current_identity_key = _feishu_app_identity_key(app_id, domain)
if not current_identity_key:
return False
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config()
previous_identity_key = ""
for spec in feishu_instance_specs(feishu_cfg, defaults):
if spec.instance_id == instance_id:
previous_identity_key = str(
spec.config.get("identityKey") or spec.config.get("identity_key") or ""
)
break
access_cleared = bool(previous_identity_key and previous_identity_key != current_identity_key)
values: dict[str, Any] = {"identityKey": current_identity_key}
if access_cleared:
values["allowFrom"] = []
values["allow_from"] = []
clear_channel(runtime_channel_name("feishu", instance_id))
if not previous_identity_key or access_cleared:
feishu_cfg = update_feishu_instance_preserving_shape(
feishu_cfg,
defaults,
instance_id,
values,
)
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
return access_cleared
def save_registration_result(
result: dict,
*,
instance_id: str = DEFAULT_INSTANCE_ID,
name: str | None = None,
) -> None:
"""Persist a successful Feishu/Lark registration result to config.json."""
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = FeishuChannel.default_config()
app_id = str(result["app_id"]).strip()
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
domain = "lark" if domain == "lark" else "feishu"
previous_identity_key = _saved_feishu_instance_identity_key(feishu_cfg, defaults, instance_id)
next_identity_key = _feishu_app_identity_key(app_id, domain)
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
identity: dict[str, str] = {}
with suppress(Exception):
identity = fetch_feishu_app_identity(
app_id,
str(result["app_secret"]),
domain,
)
values = {
"name": name or ("nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"),
"appId": app_id,
"appSecret": result["app_secret"],
"domain": domain,
"identityKey": next_identity_key,
"enabled": True,
**identity,
}
if identity_changed:
values["allowFrom"] = []
values["allow_from"] = []
clear_channel(runtime_channel_name("feishu", instance_id))
feishu_cfg = upsert_feishu_instance(
feishu_cfg,
defaults,
instance_id,
values,
)
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
"""Backfill missing Feishu assistant display identity in saved config.
Existing users may already have working App ID/Secret credentials from
older builds. Fetch identity only when an instance has credentials but no
identity metadata at all, then persist the attempt so Settings does not hit
Feishu on every render.
"""
if not FEISHU_AVAILABLE:
return False
from nanobot.config.loader import load_config, save_config
full_config = config or load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None)
defaults = FeishuChannel.default_config()
specs = feishu_instance_specs(feishu_cfg, defaults)
updated = False
for spec in specs:
instance = spec.config
if (
instance.get("displayName")
or instance.get("avatarUrl")
or instance.get("identityFetchedAt")
):
continue
app_id = str(instance.get("appId") or instance.get("app_id") or "").strip()
app_secret = str(instance.get("appSecret") or instance.get("app_secret") or "").strip()
if not app_id or not app_secret:
continue
identity = fetch_feishu_app_identity(
app_id,
app_secret,
str(instance.get("domain") or "feishu"),
)
if not identity:
identity = {"identityFetchedAt": _identity_timestamp()}
feishu_cfg = update_feishu_instance_preserving_shape(
feishu_cfg,
defaults,
spec.instance_id,
identity,
)
updated = True
if not updated:
return False
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
return True
def qr_register(
*,
initial_domain: str = "feishu",
@@ -600,7 +878,7 @@ class FeishuChannel(BaseChannel):
self.config: FeishuConfig = config
self._client: Any = None
self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None
self._ws_runner = get_feishu_ws_runner()
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
self._loop: asyncio.AbstractEventLoop | None = None
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
@@ -650,18 +928,11 @@ class FeishuChannel(BaseChannel):
self.config.app_secret = result["app_secret"]
self.config.domain = result.get("domain", "feishu")
# Write credentials back to config.json
from nanobot.config.loader import load_config, save_config
full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
if isinstance(feishu_cfg, dict):
feishu_cfg["appId"] = result["app_id"]
feishu_cfg["appSecret"] = result["app_secret"]
feishu_cfg["domain"] = result.get("domain", "feishu")
feishu_cfg["enabled"] = True
setattr(full_config.channels, "feishu", feishu_cfg)
save_config(full_config)
save_registration_result(
result,
instance_id=self.config.instance_id,
name=self.config.name,
)
_LOGIN_CONSOLE.print("\n[green]Feishu/Lark login complete.[/green]")
_LOGIN_CONSOLE.print(f"App ID: {escape(result['app_id'])}")
@@ -687,6 +958,18 @@ class FeishuChannel(BaseChannel):
)
return
if sync_saved_feishu_identity_boundary(
instance_id=self.config.instance_id,
app_id=self.config.app_id,
domain=self.config.domain,
):
self.config.identity_key = _feishu_app_identity_key(self.config.app_id, self.config.domain)
self.config.allow_from = []
self.logger.info(
"Feishu app identity changed for {}; cleared paired users for this assistant",
self.name,
)
lark, feishu_domain, lark_domain = await asyncio.to_thread(_load_lark_runtime)
redirect_lib_logging("Lark")
@@ -745,38 +1028,7 @@ class FeishuChannel(BaseChannel):
log_level=lark.LogLevel.INFO,
)
# Start WebSocket client in a separate thread with reconnect loop.
# A dedicated event loop is created for this thread so that lark_oapi's
# module-level `loop = asyncio.get_event_loop()` picks up an idle loop
# instead of the already-running main asyncio loop, which would cause
# "This event loop is already running" errors.
def run_ws():
import time
import lark_oapi.ws.client as _lark_ws_client
previous_loop = getattr(_lark_ws_client, "loop", None)
ws_loop = asyncio.new_event_loop()
asyncio.set_event_loop(ws_loop)
# Patch the module-level loop used by lark's ws Client.start()
_lark_ws_client.loop = ws_loop
try:
while self._running:
try:
self._ws_client.start()
except Exception as e:
self.logger.warning("WebSocket error: {}", e)
if self._running:
time.sleep(5)
finally:
if getattr(_lark_ws_client, "loop", None) is ws_loop:
_lark_ws_client.loop = previous_loop
with suppress(Exception):
asyncio.set_event_loop(None)
ws_loop.close()
self._ws_thread = threading.Thread(target=run_ws, daemon=True)
self._ws_thread.start()
await self._ws_runner.start_client(self.name, self._ws_client)
# Fetch bot's own open_id for accurate @mention matching
self._bot_open_id = await asyncio.get_running_loop().run_in_executor(
@@ -803,6 +1055,7 @@ class FeishuChannel(BaseChannel):
Reference: https://github.com/larksuite/oapi-sdk-python/blob/v2_main/lark_oapi/ws/client.py#L86
"""
self._running = False
await self._ws_runner.stop_client(self.name)
self.logger.info("bot stopped")
def _fetch_bot_open_id(self) -> str | None:
@@ -1589,13 +1842,95 @@ class FeishuChannel(BaseChannel):
response.msg,
response.get_log_id(),
)
if msg_type == "interactive":
return self._reply_interactive_fallback_sync(
parent_message_id,
content,
reply_in_thread=reply_in_thread,
)
return False
self.logger.debug("reply sent to message {}", parent_message_id)
return True
except Exception:
self.logger.exception("Error replying to message {}", parent_message_id)
if msg_type == "interactive":
return self._reply_interactive_fallback_sync(
parent_message_id,
content,
reply_in_thread=reply_in_thread,
)
return False
@staticmethod
def _interactive_content_to_text(content: str) -> str | None:
try:
payload = json.loads(content)
except (TypeError, json.JSONDecodeError):
return None
parts = [part.strip() for part in _extract_interactive_content(payload) if part.strip()]
text = "\n".join(parts).strip()
return text or None
@staticmethod
def _fallback_text_chunks(text: str, limit: int = 3500) -> list[str]:
text = text.strip()
if not text:
return []
chunks: list[str] = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
split_at = remaining.rfind("\n", 0, limit)
if split_at < limit // 2:
split_at = limit
chunks.append(remaining[:split_at].strip())
remaining = remaining[split_at:].strip()
return [chunk for chunk in chunks if chunk]
def _reply_interactive_fallback_sync(
self,
parent_message_id: str,
content: str,
*,
reply_in_thread: bool = False,
) -> bool:
text = self._interactive_content_to_text(content)
if not text:
return False
sent = False
for chunk in self._fallback_text_chunks(text):
body = json.dumps({"text": chunk}, ensure_ascii=False)
sent = self._reply_message_sync(
parent_message_id,
"text",
body,
reply_in_thread=reply_in_thread,
) or sent
if sent:
self.logger.warning("Sent Feishu interactive reply as text fallback")
return sent
def _send_interactive_fallback_sync(
self,
receive_id_type: str,
receive_id: str,
content: str,
) -> str | None:
text = self._interactive_content_to_text(content)
if not text:
return None
last_message_id: str | None = None
for chunk in self._fallback_text_chunks(text):
body = json.dumps({"text": chunk}, ensure_ascii=False)
message_id = self._send_message_sync(receive_id_type, receive_id, "text", body)
if message_id:
last_message_id = message_id
if last_message_id:
self.logger.warning("Sent Feishu interactive message as text fallback")
return last_message_id
def _should_use_reply_in_thread(self, metadata: dict[str, Any]) -> bool:
"""Return whether a group reply should create a Feishu thread/topic."""
return metadata.get("chat_type", "group") == "group" and self.config.reply_to_message
@@ -1639,6 +1974,12 @@ class FeishuChannel(BaseChannel):
response.msg,
response.get_log_id(),
)
if msg_type == "interactive":
return self._send_interactive_fallback_sync(
receive_id_type,
receive_id,
content,
)
return None
msg_id = getattr(response.data, "message_id", None)
self.logger.debug("{} message sent to {}: {}", msg_type, receive_id, msg_id)
@@ -2135,11 +2476,15 @@ class FeishuChannel(BaseChannel):
Sync handler for incoming messages (called from WebSocket thread).
Schedules async handling in the main event loop.
"""
if not self._running:
return
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
async def _on_message(self, data: P2ImMessageReceiveV1) -> None:
"""Handle incoming message from Feishu."""
if not self._running:
return
try:
event = data.event
message = event.message
@@ -2290,9 +2635,9 @@ class FeishuChannel(BaseChannel):
# Private chat: no override — same behavior as Telegram/Slack.
if chat_type == "group":
if self.config.topic_isolation:
session_key = f"feishu:{chat_id}:{root_id or message_id}"
session_key = f"{self.name}:{chat_id}:{root_id or message_id}"
else:
session_key = f"feishu:{chat_id}"
session_key = f"{self.name}:{chat_id}"
else:
session_key = None
+270 -64
View File
@@ -24,6 +24,7 @@ from nanobot.bus.outbound_events import (
replace_outbound_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels._feishu_instances import ChannelInstanceSpec, feishu_instance_specs
from nanobot.channels.base import BaseChannel
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.schema import Config
@@ -61,6 +62,10 @@ def _default_channel_config(name: str) -> dict[str, Any] | None:
def _channel_config_enabled(name: str, section: Any) -> bool:
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True))
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if isinstance(section, dict):
return bool(section.get("enabled", default_enabled))
@@ -104,11 +109,108 @@ class ChannelManager:
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self.channels: dict[str, BaseChannel] = {}
self._channel_tasks: dict[str, asyncio.Task] = {}
self._dispatch_task: asyncio.Task | None = None
self._started = False
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
self._init_channels()
def _config_extra_channel_names(self, config: Config | None = None) -> set[str]:
extra = getattr((config or self.config).channels, "__pydantic_extra__", None) or {}
return set(extra.keys())
def _channel_section(
self,
name: str,
*,
config: Config | None = None,
default_sections: dict[str, Any] | None = None,
) -> Any:
config = config or self.config
section = getattr(config.channels, name, None)
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
return section
if default_sections is None:
return _default_channel_config(name)
if name not in default_sections:
default = _default_channel_config(name)
if default is not None:
default_sections[name] = default
return default_sections.get(name)
def _channel_instance_specs(
self,
name: str,
cls: type[BaseChannel],
section: Any,
*,
enabled_only: bool = True,
) -> list[ChannelInstanceSpec]:
if name == "feishu":
return feishu_instance_specs(
section,
cls.default_config(),
enabled_only=enabled_only,
)
return [
ChannelInstanceSpec(
base_name=name,
instance_id="default",
runtime_name=name,
config=section,
)
]
def _build_channel(
self,
name: str,
cls: type[BaseChannel],
section: Any,
*,
runtime_name: str | None = None,
) -> BaseChannel:
kwargs: dict[str, Any] = {}
if cls.name == "websocket":
from nanobot.channels.websocket import WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
parsed = WebSocketConfig.model_validate(section)
static_path = _default_webui_dist() if self._webui_static_dist else None
workspace = Path(self.config.workspace_path)
gateway = build_gateway_services(
config=parsed,
bus=self.bus,
session_manager=self._session_manager,
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action,
logger=logger,
)
kwargs["gateway"] = gateway
channel = cls(section, self.bus, **kwargs)
if runtime_name and runtime_name != channel.name:
channel.name = runtime_name
channel.send_progress = self._resolve_bool_override(
section, "send_progress", self.config.channels.send_progress,
)
channel.send_tool_hints = self._resolve_bool_override(
section, "send_tool_hints", self.config.channels.send_tool_hints,
)
channel.show_reasoning = self._resolve_bool_override(
section, "show_reasoning", self.config.channels.show_reasoning,
)
return channel
def _init_channels(self) -> None:
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
from nanobot.channels.registry import discover_channel_names, discover_enabled
@@ -118,24 +220,12 @@ class ChannelManager:
# extra="allow"), so we enumerate candidates from pkgutil scan
# (cheap, no imports) and any plugin keys in __pydantic_extra__.
names = discover_channel_names()
candidate_names = set(names)
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
candidate_names.update(extra.keys())
candidate_names = set(names) | self._config_extra_channel_names()
default_sections: dict[str, Any] = {}
def section_for(name: str) -> Any:
section = getattr(self.config.channels, name, None)
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
return section
if name not in default_sections:
default = _default_channel_config(name)
if default is not None:
default_sections[name] = default
return default_sections.get(name)
enabled_names: set[str] = set()
for name in candidate_names:
section = section_for(name)
section = self._channel_section(name, default_sections=default_sections)
if section is None:
continue
if _channel_config_enabled(name, section):
@@ -146,48 +236,18 @@ class ChannelManager:
_names=names,
warn_import_errors=True,
).items():
section = section_for(name)
section = self._channel_section(name, default_sections=default_sections)
if section is None:
continue
try:
kwargs: dict[str, Any] = {}
if cls.name == "websocket":
from nanobot.channels.websocket import WebSocketConfig
from nanobot.webui.gateway_services import build_gateway_services
parsed = WebSocketConfig.model_validate(section)
static_path = _default_webui_dist() if self._webui_static_dist else None
workspace = Path(self.config.workspace_path)
gateway = build_gateway_services(
config=parsed,
bus=self.bus,
session_manager=self._session_manager,
static_dist_path=static_path,
workspace_path=workspace,
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
disabled_skills=set(self.config.agents.defaults.disabled_skills),
runtime_model_name=self._webui_runtime_model_name,
runtime_surface=self._webui_runtime_surface,
runtime_capabilities_overrides=self._webui_runtime_capabilities,
cron_service=self._cron_service,
local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
logger=logger,
for spec in self._channel_instance_specs(name, cls, section):
self.channels[spec.runtime_name] = self._build_channel(
name,
cls,
spec.config,
runtime_name=spec.runtime_name,
)
kwargs["gateway"] = gateway
channel = cls(section, self.bus, **kwargs)
channel.send_progress = self._resolve_bool_override(
section, "send_progress", self.config.channels.send_progress,
)
channel.send_tool_hints = self._resolve_bool_override(
section, "send_tool_hints", self.config.channels.send_tool_hints,
)
channel.show_reasoning = self._resolve_bool_override(
section, "show_reasoning", self.config.channels.show_reasoning,
)
self.channels[name] = channel
logger.info("{} channel enabled", cls.display_name)
logger.info("{} channel enabled as {}", cls.display_name, spec.runtime_name)
except Exception as e:
logger.warning("{} channel not available: {}", name, e)
@@ -243,20 +303,173 @@ class ChannelManager:
except Exception:
logger.exception("Failed to start channel {}", name)
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task:
logger.info("Starting {} channel...", name)
task = asyncio.create_task(self._start_channel(name, channel))
self._channel_tasks[name] = task
return task
async def _stop_channel(self, name: str) -> bool:
channel = self.channels.get(name)
if channel is None:
self._channel_tasks.pop(name, None)
return False
task = self._channel_tasks.pop(name, None)
try:
await channel.stop()
logger.info("Stopped {} channel", name)
except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling():
raise
logger.debug("Channel {} stop task was already cancelled", name)
except Exception:
logger.exception("Error stopping {}", name)
if task is not None and not task.done():
task.cancel()
with suppress(asyncio.CancelledError):
await task
return True
def _is_known_channel_name(self, name: str) -> bool:
from nanobot.channels.registry import discover_channel_names, discover_plugins
return name in set(discover_channel_names()) or name in discover_plugins()
def _load_channel_class(self, name: str) -> type[BaseChannel] | None:
from nanobot.channels.registry import discover_channel_names, discover_enabled
names = discover_channel_names()
return discover_enabled({name}, _names=names, warn_import_errors=True).get(name)
async def apply_channel_feature_action(self, action: str, name: str) -> dict[str, Any]:
"""Apply a WebUI channel enable/disable action without restarting the gateway.
Returns a small transport-neutral result. ``handled=False`` means the
optional feature is not a channel and should keep the default feature
response semantics.
"""
name = name.strip()
instance_id = ""
if "." in name:
name, instance_id = name.split(".", 1)
if not name or not self._is_known_channel_name(name):
return {"handled": False}
if name == "websocket":
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": "WebSocket hosts the WebUI and is applied on restart.",
}
from nanobot.config.loader import load_config
self.config = load_config()
section = self._channel_section(name)
if action == "disable":
runtime_names = [name if not instance_id else f"{name}.{instance_id}"]
if name == "feishu" and not instance_id:
runtime_names = [
runtime_name
for runtime_name in self.channels
if runtime_name == "feishu" or runtime_name.startswith("feishu.")
]
stopped = False
for runtime_name in runtime_names:
stopped = await self._stop_channel(runtime_name) or stopped
self.channels.pop(runtime_name, None)
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": f"{name} channel stopped." if stopped else f"{name} channel disabled.",
}
if action != "enable":
return {"handled": True, "ok": False, "requires_restart": True}
if section is None or not _channel_config_enabled(name, section):
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel config was not enabled.",
}
cls = self._load_channel_class(name)
if cls is None:
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel could not be loaded.",
}
specs = self._channel_instance_specs(name, cls, section)
if instance_id:
specs = [spec for spec in specs if spec.instance_id == instance_id]
if not specs:
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel config was not enabled.",
}
try:
built = [
(
spec.runtime_name,
self._build_channel(
name,
cls,
spec.config,
runtime_name=spec.runtime_name,
),
)
for spec in specs
]
except Exception as exc:
logger.exception("Failed to build {} channel after settings change", name)
return {
"handled": True,
"ok": False,
"requires_restart": True,
"message": f"{name} channel could not be started: {exc}",
}
for runtime_name, _channel in built:
if runtime_name in self.channels:
await self._stop_channel(runtime_name)
for runtime_name, channel in built:
self.channels[runtime_name] = channel
if self._started:
self._start_channel_task(runtime_name, channel)
logger.info("{} channel applied without restart", runtime_name)
return {
"handled": True,
"ok": True,
"requires_restart": False,
"message": f"{cls.display_name} channel applied without restart.",
}
async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher."""
if not self.channels:
logger.warning("No channels enabled")
return
self._started = True
# Start outbound dispatcher
self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
# Start channels
tasks = []
for name, channel in self.channels.items():
logger.info("Starting {} channel...", name)
tasks.append(asyncio.create_task(self._start_channel(name, channel)))
tasks.append(self._start_channel_task(name, channel))
self._notify_restart_done_if_needed()
@@ -284,6 +497,7 @@ class ChannelManager:
async def stop_all(self) -> None:
"""Stop all channels and the dispatcher."""
logger.info("Stopping all channels...")
self._started = False
# Stop dispatcher
if self._dispatch_task:
@@ -292,16 +506,8 @@ class ChannelManager:
await self._dispatch_task
# Stop all channels
for name, channel in self.channels.items():
try:
await channel.stop()
logger.info("Stopped {} channel", name)
except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling():
raise
logger.debug("Channel {} stop task was already cancelled", name)
except Exception:
logger.exception("Error stopping {}", name)
for name in list(self.channels):
await self._stop_channel(name)
@staticmethod
def _fingerprint_content(content: str) -> str:
+56 -3
View File
@@ -1,15 +1,17 @@
"""Matrix (Element) channel — inbound sync + outbound message/media delivery."""
import asyncio
import html
import json
import mimetypes
import re
import sys
import time
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypeAlias
from urllib.parse import quote, urlparse
from urllib.parse import quote, unquote, urlparse
from pydantic import Field
@@ -92,6 +94,19 @@ MATRIX_ALLOWED_HTML_ATTRIBUTES: dict[str, set[str]] = {
"img": {"src", "alt", "title", "width", "height"},
}
MATRIX_ALLOWED_URL_SCHEMES = {"https", "http", "matrix", "mailto", "mxc"}
_MXC_IMAGE_PLACEHOLDER_PREFIX = "https://nanobot.invalid/matrix-mxc/"
_MXC_MARKDOWN_IMAGE_RE = re.compile(
r"(?P<prefix>!\[[^\]]*\]\()"
r"(?P<value>mxc://[^\s)]+)"
r"(?P<suffix>(?:\s+[^)]*)?\))"
)
_MXC_IMAGE_SRC_RE = re.compile(
r"(?P<prefix>\bsrc=)(?P<quote>[\"'])(?P<value>mxc://[^\"']+)(?P=quote)",
re.IGNORECASE,
)
_MXC_PLACEHOLDER_SRC_RE = re.compile(
rf'src="{re.escape(_MXC_IMAGE_PLACEHOLDER_PREFIX)}([^"]+)"'
)
def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None:
@@ -99,7 +114,10 @@ def _filter_matrix_html_attribute(tag: str, attr: str, value: str) -> str | None
if tag == "a" and attr == "href":
return value if value.lower().startswith(("https://", "http://", "matrix:", "mailto:")) else None
if tag == "img" and attr == "src":
return value if value.lower().startswith("mxc://") else None
lowered = value.lower()
if lowered.startswith("mxc://") or lowered.startswith(_MXC_IMAGE_PLACEHOLDER_PREFIX):
return value
return None
if tag == "code" and attr == "class":
classes = [c for c in value.split() if c.startswith("language-") and not c.startswith("language-_")]
return " ".join(classes) if classes else None
@@ -115,6 +133,39 @@ MATRIX_HTML_CLEANER = nh3.Cleaner(
link_rel="noopener noreferrer",
)
def _mask_mxc_markdown_image_sources(text: str) -> str:
def repl(match: re.Match[str]) -> str:
value = quote(match.group("value"), safe="")
return (
f"{match.group('prefix')}"
f"{_MXC_IMAGE_PLACEHOLDER_PREFIX}{value}"
f"{match.group('suffix')}"
)
return _MXC_MARKDOWN_IMAGE_RE.sub(repl, text)
def _mask_mxc_image_sources(rendered_html: str) -> str:
def repl(match: re.Match[str]) -> str:
value = quote(match.group("value"), safe="")
return (
f'{match.group("prefix")}{match.group("quote")}'
f"{_MXC_IMAGE_PLACEHOLDER_PREFIX}{value}"
f'{match.group("quote")}'
)
return _MXC_IMAGE_SRC_RE.sub(repl, rendered_html)
def _unmask_mxc_image_sources(cleaned_html: str) -> str:
def repl(match: re.Match[str]) -> str:
value = html.escape(unquote(match.group(1)), quote=True)
return f'src="{value}"'
return _MXC_PLACEHOLDER_SRC_RE.sub(repl, cleaned_html)
@dataclass
class _StreamBuf:
"""
@@ -135,7 +186,9 @@ class _StreamBuf:
def _render_markdown_html(text: str) -> str | None:
"""Render markdown to sanitized HTML; returns None for plain text."""
try:
formatted = MATRIX_HTML_CLEANER.clean(MATRIX_MARKDOWN(text)).strip()
masked_text = _mask_mxc_markdown_image_sources(text)
rendered = _mask_mxc_image_sources(MATRIX_MARKDOWN(masked_text))
formatted = _unmask_mxc_image_sources(MATRIX_HTML_CLEANER.clean(rendered).strip())
except Exception:
return None
if not formatted:
+6 -2
View File
@@ -10,7 +10,11 @@ from loguru import logger
if TYPE_CHECKING:
from nanobot.channels.base import BaseChannel
_INTERNAL = frozenset({"base", "manager", "registry"})
_INTERNAL = frozenset({
"base",
"manager",
"registry",
})
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
@@ -21,7 +25,7 @@ def discover_channel_names() -> list[str]:
return [
name
for _, name, ispkg in pkgutil.iter_modules(pkg.__path__)
if name not in _INTERNAL and not ispkg
if name not in _INTERNAL and not name.startswith("_") and not ispkg
]
+252 -46
View File
@@ -5,6 +5,7 @@ import os
import select
import signal
import sys
import time
from collections.abc import Callable, Iterable
from contextlib import nullcontext, suppress
from pathlib import Path
@@ -75,6 +76,7 @@ from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
from nanobot.config.schema import Config # noqa: E402
from nanobot.security.network import is_loopback_host # noqa: E402
from nanobot.utils.evaluator import evaluate_response # noqa: E402
from nanobot.utils.helpers import sync_workspace_templates # noqa: E402
from nanobot.utils.restart import ( # noqa: E402
@@ -82,6 +84,11 @@ from nanobot.utils.restart import ( # noqa: E402
format_restart_completed_message,
should_show_cli_restart_notice,
)
from nanobot.webui.build import ( # noqa: E402
BuildMode,
WebUIBuildError,
ensure_webui_bundle,
)
from nanobot.webui.sidebar_state import read_webui_sidebar_state # noqa: E402
@@ -724,25 +731,12 @@ def onboard(
)
def _merge_missing_defaults(existing: Any, defaults: Any) -> Any:
"""Recursively fill in missing values from defaults without overwriting user config."""
if not isinstance(existing, dict) or not isinstance(defaults, dict):
return existing
merged = dict(existing)
for key, value in defaults.items():
if key not in merged:
merged[key] = value
else:
merged[key] = _merge_missing_defaults(merged[key], value)
return merged
def _onboard_plugins(config_path: Path) -> None:
"""Inject default config for all discovered channels (built-in + plugins)."""
import json
from nanobot.channels.registry import discover_all
from nanobot.config.loader import merge_missing_defaults
all_channels = discover_all()
if not all_channels:
@@ -756,7 +750,7 @@ def _onboard_plugins(config_path: Path) -> None:
if name not in channels:
channels[name] = cls.default_config()
else:
channels[name] = _merge_missing_defaults(channels[name], cls.default_config())
channels[name] = merge_missing_defaults(channels[name], cls.default_config())
with open(config_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
@@ -883,11 +877,7 @@ def _confirm_webui_action(message: str, *, yes: bool) -> None:
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
if yes:
return
try:
interactive = sys.stdin.isatty()
except Exception:
interactive = False
if not interactive:
if not _cli_can_prompt():
console.print(
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
"`nanobot onboard --wizard`.[/red]"
@@ -898,6 +888,19 @@ def _confirm_webui_action(message: str, *, yes: bool) -> None:
raise typer.Exit(1)
def _cli_can_prompt() -> bool:
try:
return sys.stdin.isatty()
except Exception:
return False
def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
if yes:
return "auto"
return "prompt" if _cli_can_prompt() else "warn"
def _resolve_webui_config_path(config: str | None) -> Path:
"""Resolve the config path used by ``nanobot webui`` and bind loader state."""
from nanobot.config.loader import get_config_path, set_config_path
@@ -942,6 +945,43 @@ def _webui_config_dict(config: Config) -> dict[str, Any]:
return model.model_dump(by_alias=True, exclude_none=True)
def _webui_channel_enabled(config: Config) -> bool:
from nanobot.channels.websocket import WebSocketConfig
current = getattr(config.channels, "websocket", None) or {}
return bool(WebSocketConfig.model_validate(current).enabled)
def _prepare_webui_bundle_for_gateway(
config: Config,
*,
mode: BuildMode,
webui_static_dist: bool = True,
) -> None:
"""Refresh or warn about stale bundled WebUI assets before gateway startup."""
if not webui_static_dist or not _webui_channel_enabled(config):
return
def _print(message: str) -> None:
console.print(f"[yellow]{escape(message)}[/yellow]")
def _confirm(message: str) -> bool:
return typer.confirm(message, default=True)
try:
ensure_webui_bundle(
mode=mode,
confirm=_confirm if mode == "prompt" else None,
output=_print,
)
except WebUIBuildError as exc:
if mode == "warn":
console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
return
console.print(f"[red]Error: {escape(str(exc))}[/red]")
raise typer.Exit(1) from exc
def _host_for_local_browser(host: str) -> str:
"""Map bind hosts to a browser-openable local host."""
if host in {"0.0.0.0", ""}:
@@ -1041,7 +1081,6 @@ def _warn_webui_bind_scope(config: Config) -> None:
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
"""Best-effort wait for the WebUI listener before opening a browser."""
import socket
import time
from urllib.parse import urlparse
@@ -1050,11 +1089,75 @@ def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
port = parsed.port or (443 if parsed.scheme == "https" else 80)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=0.2):
return
except OSError:
time.sleep(0.1)
if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
return
time.sleep(0.1)
def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
"""Return whether a local TCP endpoint accepts connections."""
import socket
try:
with socket.create_connection((host, port), timeout=timeout_s):
return True
except OSError:
return False
def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
"""Return whether the nanobot gateway health endpoint responds OK."""
import json
import urllib.error
import urllib.request
browser_host = _host_for_local_browser(host)
try:
with urllib.request.urlopen(
f"http://{browser_host}:{port}/health",
timeout=timeout_s,
) as response:
if response.status != 200:
return False
body = response.read(1024)
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
return False
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return False
return payload.get("status") == "ok"
def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
"""Return whether the WebUI URL's TCP endpoint is already listening."""
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
def _print_foreground_port_conflict(
*,
webui_url: str,
gateway_host: str,
gateway_port: int,
) -> None:
console.print(
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
)
console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
console.print(
f" Gateway health: [cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
)
console.print()
console.print("If this is an existing nanobot instance, use it or stop it first:")
console.print(" [cyan]nanobot gateway status[/cyan]")
console.print(" [cyan]nanobot gateway stop[/cyan]")
console.print("Or choose different ports with [cyan]--port[/cyan] and [cyan]--gateway-port[/cyan].")
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
@@ -1063,11 +1166,41 @@ def _open_webui_browser(url: str, *, wait: bool = True) -> None:
if wait:
_wait_for_webui(url)
display_url = _webui_display_url(url)
try:
webbrowser.open(url)
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{url}[/cyan]")
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
except Exception as exc:
console.print(f"[yellow]Could not open browser ({exc}); visit {url}[/yellow]")
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
"""Explain how the browser and gateway lifecycles differ."""
console.print()
if attached:
console.print("[green]nanobot is attached to the existing gateway.[/green]")
else:
console.print("[green]nanobot is running in this terminal.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
def _attach_to_background_gateway(runtime: Any) -> None:
"""Keep a foreground WebUI command attached to a managed gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
while runtime.status().running:
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping nanobot...[/yellow]")
result = runtime.stop()
if result.ok or result.message == "gateway_not_running":
console.print("[green]Gateway stopped.[/green]")
return
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
raise typer.Exit(1)
console.print("[yellow]Gateway stopped.[/yellow]")
def _gateway_instance_command(
@@ -1191,9 +1324,9 @@ def serve(
port = port if port is not None else api_cfg.port
timeout = timeout if timeout is not None else api_cfg.timeout
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
if host in {"0.0.0.0", "::"} and not api_key:
if not is_loopback_host(host) and not api_key:
console.print(
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
f"[red]Error: host {host} is available beyond this device but api_key is not set. "
"Set api.api_key in config to prevent unauthenticated access.[/red]"
)
raise typer.Exit(1)
@@ -1217,9 +1350,9 @@ def serve(
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
console.print(" [cyan]Session[/cyan] : api:default")
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
if host in {"0.0.0.0", "::"}:
if not is_loopback_host(host):
console.print(
"[yellow]API is bound to all interfaces "
"[yellow]API is available beyond this device "
"(authentication required).[/yellow]"
)
console.print()
@@ -1256,7 +1389,11 @@ def webui(
),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
background: bool = typer.Option(False, "--background", help="Start gateway in the background"),
background: bool = typer.Option(
False,
"--background",
help="Keep the gateway running after this command exits",
),
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
yes: bool = typer.Option(
False,
@@ -1323,21 +1460,25 @@ def webui(
f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
)
if background:
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_arg,
config_path=config_arg,
)
)
start_options = GatewayStartOptions(
port=effective_gateway_port,
webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_arg,
config_path=config_arg,
)
)
start_options = GatewayStartOptions(
port=effective_gateway_port,
workspace=workspace_arg,
config_path=config_arg,
)
if background:
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
result = runtime.start_background(start_options)
restarted = False
restart_attempted = False
@@ -1365,14 +1506,53 @@ def webui(
"View logs: "
f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
)
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print(
"Stop nanobot: "
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url)
return
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
webui_ready = _webui_endpoint_reachable(webui_url)
if gateway_ready and webui_ready:
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
console.print(
"Restart the gateway if you need it to pick up local source changes: "
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(runtime)
else:
console.print(
"[yellow]This gateway is controlled by another foreground command. "
"Stop it from that terminal.[/yellow]"
)
return
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
_host_for_local_browser(runtime_config.gateway.host),
effective_gateway_port,
)
webui_port_taken = webui_ready
if gateway_port_taken or webui_port_taken:
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=runtime_config.gateway.host,
gateway_port=effective_gateway_port,
)
raise typer.Exit(1)
_print_webui_foreground_lifecycle(attached=False)
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else webui_url,
webui_bundle_mode=webui_bundle_mode,
)
@@ -1387,6 +1567,7 @@ def _run_gateway(
port: int | None = None,
open_browser_url: str | None = None,
webui_static_dist: bool = True,
webui_bundle_mode: BuildMode = "warn",
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
@@ -1409,8 +1590,29 @@ def _run_gateway(
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
webui_url = _webui_browser_url(config)
gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
_prepare_webui_bundle_for_gateway(
config,
mode=webui_bundle_mode,
webui_static_dist=webui_static_dist,
)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
runtime_events = RuntimeEventBus()
@@ -1895,6 +2097,10 @@ app.add_typer(
log_handler_id=_log_handler_id,
load_runtime_config=_load_runtime_config,
run_gateway=_run_gateway,
prepare_webui_bundle=lambda config, mode: _prepare_webui_bundle_for_gateway(
config,
mode=mode,
),
),
name="gateway",
)
@@ -2274,7 +2480,7 @@ def plugins_list(
@plugins_app.command("enable")
def plugins_enable(
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, pdf)"),
name: str = typer.Argument(..., help="Feature name (e.g. weixin, matrix, bedrock)"),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show optional package install logs"),
):
+19 -2
View File
@@ -25,11 +25,13 @@ from nanobot.gateway.service import (
GatewayServiceResult,
ServiceManagerKind,
)
from nanobot.webui.build import BuildMode
RuntimeConfigLoader = Callable[[str | None, str | None], Config]
GatewayRunner = Callable[..., None]
GatewayRuntimeFactory = Callable[..., Any]
GatewayServiceFactory = Callable[[], Any]
WebUIBundlePreparer = Callable[[Config, BuildMode], None]
def create_gateway_app(
@@ -40,6 +42,7 @@ def create_gateway_app(
run_gateway: GatewayRunner,
runtime_factory: GatewayRuntimeFactory | None = None,
service_factory: GatewayServiceFactory | None = None,
prepare_webui_bundle: WebUIBundlePreparer | None = None,
) -> typer.Typer:
gateway_app = typer.Typer(
help="Start and manage the nanobot gateway.",
@@ -81,14 +84,20 @@ def create_gateway_app(
def service_installer():
return service_factory() if service_factory is not None else GatewayServiceInstaller()
def interactive_build_mode() -> BuildMode:
# `nanobot gateway` is often launched by tests, supervisors, or service managers.
# The higher-level `nanobot webui` command owns interactive first-run guidance.
return "warn"
def start_options(
*,
port: int | None,
verbose: bool,
workspace: str | None,
config: str | None,
loaded_config: Config | None = None,
) -> GatewayStartOptions:
cfg = load_runtime_config(config, workspace)
cfg = loaded_config or load_runtime_config(config, workspace)
resolved_config = str(Path(config).expanduser().resolve()) if config else None
resolved_workspace = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
return GatewayStartOptions(
@@ -139,6 +148,9 @@ def create_gateway_app(
console.print("[red]Error: --foreground and --background cannot be used together.[/red]")
raise typer.Exit(1)
if background:
cfg = load_runtime_config(config, workspace)
if prepare_webui_bundle is not None:
prepare_webui_bundle(cfg, interactive_build_mode())
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.start_background(
start_options(
@@ -146,6 +158,7 @@ def create_gateway_app(
verbose=verbose,
workspace=workspace,
config=config,
loaded_config=cfg,
)
)
if result.ok:
@@ -158,7 +171,7 @@ def create_gateway_app(
configure_logging(verbose)
cfg = load_runtime_config(config, workspace)
run_gateway(cfg, port=port)
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
@gateway_app.command("status")
def gateway_status(
@@ -211,6 +224,9 @@ def create_gateway_app(
timeout: int = typer.Option(20, "--timeout", help="Restart timeout in seconds"),
) -> None:
"""Restart the background gateway."""
cfg = load_runtime_config(config, workspace)
if prepare_webui_bundle is not None:
prepare_webui_bundle(cfg, interactive_build_mode())
runtime = runtime_for_instance(workspace=workspace, config=config)
result = runtime.restart(
start_options(
@@ -218,6 +234,7 @@ def create_gateway_app(
verbose=verbose,
workspace=workspace,
config=config,
loaded_config=cfg,
),
timeout_s=timeout,
)
+14
View File
@@ -89,6 +89,20 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
json.dump(data, f, indent=2, ensure_ascii=False)
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
"""Recursively add missing defaults without replacing configured values."""
if not isinstance(existing, dict) or not isinstance(defaults, dict):
return existing
merged = dict(existing)
for key, value in defaults.items():
if key not in merged:
merged[key] = value
else:
merged[key] = merge_missing_defaults(merged[key], value)
return merged
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
+24 -406
View File
@@ -1,60 +1,27 @@
"""Background process control for ``nanobot gateway``.
This module intentionally stays small: the CLI owns command wording, while this
runtime owns process state, log files, and platform-specific detach/stop details.
"""
"""Gateway-specific configuration for the shared background process runtime."""
from __future__ import annotations
import ctypes
import json
import os
import signal
import hashlib
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from nanobot.config.paths import get_data_dir
from nanobot.process_runtime import (
ManagedProcessRuntime,
ProcessResult,
ProcessRuntimePaths,
ProcessStartOptions,
ProcessStatus,
)
@dataclass(frozen=True)
class GatewayStartOptions:
"""Options needed to start a background gateway instance."""
port: int
verbose: bool = False
workspace: str | None = None
config_path: str | None = None
@dataclass(frozen=True)
class GatewayStatus:
"""Current background gateway status."""
running: bool
pid: int | None
state_path: Path
log_path: Path
started_at: str | None = None
port: int | None = None
command: tuple[str, ...] = ()
reason: str = "not_started"
@dataclass(frozen=True)
class RuntimeResult:
"""Result from a gateway runtime control operation."""
ok: bool
message: str
status: GatewayStatus
GatewayStartOptions = ProcessStartOptions
GatewayStatus = ProcessStatus
RuntimeResult = ProcessResult
def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]:
@@ -78,14 +45,9 @@ def build_gateway_command(python_executable: str, options: GatewayStartOptions)
@dataclass(frozen=True)
class GatewayRuntimePaths:
class GatewayRuntimePaths(ProcessRuntimePaths):
"""Filesystem layout for one gateway runtime instance."""
run_dir: Path
logs_dir: Path
state_path: Path
log_path: Path
@classmethod
def for_instance(
cls,
@@ -107,9 +69,11 @@ class GatewayRuntimePaths:
)
class GatewayRuntime:
class GatewayRuntime(ManagedProcessRuntime):
"""Manage a background ``nanobot gateway`` process."""
service_name = "gateway"
def __init__(
self,
*,
@@ -120,367 +84,21 @@ class GatewayRuntime:
subprocess_run: Callable[..., Any] = subprocess.run,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.paths = paths or GatewayRuntimePaths.for_instance()
self.platform_name = platform_name or _platform_name()
self.python_executable = python_executable or sys.executable
self._popen = popen
self._subprocess_run = subprocess_run
self._sleep = sleep
@classmethod
def refresh_state_pid(cls, *, paths: GatewayRuntimePaths) -> None:
"""Update the PID in an existing state file to ``os.getpid()``.
Called early in gateway server startup so the state file self-heals
after any restart, regardless of platform or restart mechanism.
"""
if not paths.state_path.exists():
return
try:
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
state["pid"] = os.getpid()
rt = cls(paths=paths)
state["identity"] = rt._process_identity(os.getpid())
state["started_at"] = _utc_now()
rt._write_state(state)
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
"""Start gateway as a detached background process."""
current = self.status()
if current.running:
return RuntimeResult(False, "gateway_already_running", current)
command = self._build_child_command(options)
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
with self.paths.log_path.open("a", encoding="utf-8") as log_handle:
process = self._popen(
command,
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=subprocess.STDOUT,
**self._popen_platform_kwargs(),
)
pid = int(process.pid)
self._sleep(0.2)
if not self._is_pid_running(pid):
return RuntimeResult(False, "gateway_exited_during_startup", self.status())
identity = self._process_identity(pid)
self._write_state(
{
"pid": pid,
"identity": identity,
"started_at": _utc_now(),
"platform": self.platform_name,
"port": options.port,
"workspace": options.workspace,
"config_path": options.config_path,
"command": command,
"log_path": str(self.paths.log_path),
}
)
return RuntimeResult(True, "gateway_started_background", self.status())
def stop(self, *, timeout_s: int = 20) -> RuntimeResult:
"""Stop the recorded background gateway process."""
status = self.status()
if not status.pid:
return RuntimeResult(False, "gateway_not_running", status)
state = self._read_state()
if not self._record_matches_process(state, status.pid):
self._clear_state()
return RuntimeResult(False, "gateway_state_stale", self.status(reason="stale_state"))
if not self._terminate(status.pid, timeout_s=timeout_s):
return RuntimeResult(False, "gateway_stop_timeout", self.status(reason="stop_timeout"))
self._clear_state()
return RuntimeResult(True, "gateway_stopped", self.status(reason="stopped"))
def restart(self, options: GatewayStartOptions, *, timeout_s: int = 20) -> RuntimeResult:
"""Restart the background gateway."""
stop_result = self.stop(timeout_s=timeout_s)
if not stop_result.ok and stop_result.message not in {"gateway_not_running", "gateway_state_stale"}:
return stop_result
return self.start_background(options)
def status(self, *, reason: str | None = None) -> GatewayStatus:
"""Return live status, clearing stale state when needed."""
state = self._read_state()
pid = _as_int(state.get("pid")) if state else None
if pid is None:
return GatewayStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "not_started",
)
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
self._clear_state()
return GatewayStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "stale_state",
)
command = state.get("command")
return GatewayStatus(
running=True,
pid=pid,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
started_at=_as_str(state.get("started_at")),
port=_as_int(state.get("port")),
command=tuple(command) if isinstance(command, list) else (),
reason=reason or "running",
super().__init__(
paths=paths or GatewayRuntimePaths.for_instance(),
platform_name=platform_name,
python_executable=python_executable,
popen=popen,
subprocess_run=subprocess_run,
sleep=sleep,
)
def read_log_tail(self, *, tail: int = 200) -> list[str]:
"""Return the last ``tail`` log lines."""
if tail <= 0 or not self.paths.log_path.exists():
return []
try:
lines = self.paths.log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
return lines[-tail:]
def follow_logs(self, *, tail: int = 200) -> int:
"""Print existing log tail and follow new log lines."""
for line in self.read_log_tail(tail=tail):
print(line)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
self.paths.log_path.touch(exist_ok=True)
try:
with self.paths.log_path.open("r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
while True:
line = handle.readline()
if line:
print(line.rstrip("\n"))
else:
self._sleep(0.5)
except KeyboardInterrupt:
return 130
def _build_child_command(self, options: GatewayStartOptions) -> list[str]:
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
return build_gateway_command(self.python_executable, options)
def _popen_platform_kwargs(self) -> dict[str, Any]:
if self.platform_name == "Windows":
flags = 0
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
return {"creationflags": flags}
return {"start_new_session": True}
def _terminate(self, pid: int, *, timeout_s: int) -> bool:
if self.platform_name == "Windows":
return self._terminate_windows(pid, timeout_s=timeout_s)
return self._terminate_posix(pid, timeout_s=timeout_s)
def _terminate_posix(self, pid: int, *, timeout_s: int) -> bool:
try:
pgid = os.getpgid(pid)
except OSError:
pgid = None
try:
if pgid is not None:
os.killpg(pgid, signal.SIGTERM)
else:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return True
if self._wait_for_exit(pid, timeout_s):
return True
with suppress(ProcessLookupError):
if pgid is not None:
os.killpg(pgid, signal.SIGKILL)
else:
os.kill(pid, signal.SIGKILL)
return self._wait_for_exit(pid, 2)
def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool:
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
if ctrl_break is not None:
# Detached Windows children can reject CTRL_BREAK_EVENT with WinError 87;
# keep the existing taskkill fallback for that process shape.
ctrl_break_sent = False
try:
os.kill(pid, ctrl_break)
except ProcessLookupError:
return True
except OSError:
pass
else:
ctrl_break_sent = True
if ctrl_break_sent and self._wait_for_exit(pid, timeout_s):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if self._wait_for_exit(pid, 2):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return self._wait_for_exit(pid, 2)
def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool:
deadline = time.monotonic() + max(float(timeout_s), 0.0)
while time.monotonic() < deadline:
if not self._is_pid_running(pid):
return True
self._sleep(0.1)
return not self._is_pid_running(pid)
def _is_pid_running(self, pid: int) -> bool:
if pid <= 0:
return False
if self.platform_name == "Windows":
return _windows_process_identity(pid) is not None
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _process_identity(self, pid: int) -> str | int | None:
if self.platform_name == "Windows":
return _windows_process_identity(pid)
try:
return os.getpgid(pid)
except OSError:
return None
def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool:
if not state:
return False
recorded = state.get("identity")
if recorded is None:
return True
return recorded == self._process_identity(pid)
def _read_state(self) -> dict[str, Any] | None:
try:
with self.paths.state_path.open(encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _write_state(self, payload: dict[str, Any]) -> None:
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f"{self.paths.state_path.name}.",
suffix=".tmp",
dir=self.paths.run_dir,
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(self.paths.state_path)
finally:
tmp_path.unlink(missing_ok=True)
def _clear_state(self) -> None:
self.paths.state_path.unlink(missing_ok=True)
def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | None:
raw = "|".join(value for value in (workspace, config_path) if value)
if not raw:
return None
import hashlib
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
def _platform_name() -> str:
if sys.platform.startswith("win"):
return "Windows"
if sys.platform == "darwin":
return "Darwin"
return "Linux"
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _as_int(value: object) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
return None
return None
def _as_str(value: object) -> str | None:
return value if isinstance(value, str) else None
def _windows_process_identity(pid: int) -> str | None:
if os.name != "nt":
return None
class FileTime(ctypes.Structure):
_fields_ = [("low", ctypes.c_uint32), ("high", ctypes.c_uint32)]
@property
def value(self) -> int:
return (int(self.high) << 32) | int(self.low)
process_query_limited_information = 0x1000
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
if not handle:
return None
try:
creation_time = FileTime()
exit_time = FileTime()
kernel_time = FileTime()
user_time = FileTime()
ok = kernel32.GetProcessTimes(
handle,
ctypes.byref(creation_time),
ctypes.byref(exit_time),
ctypes.byref(kernel_time),
ctypes.byref(user_time),
)
if not ok:
return None
exit_code = ctypes.c_uint32()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return None
if exit_code.value != 259:
return None
return str(creation_time.value)
finally:
kernel32.CloseHandle(handle)
+237 -28
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import subprocess
import sys
from contextlib import suppress
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
@@ -13,7 +14,19 @@ from loguru import logger
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from nanobot.channels._feishu_instances import (
DEFAULT_INSTANCE_ID,
feishu_instance_specs,
set_feishu_instance_enabled,
)
from nanobot.channels._setup import (
channel_field_value,
channel_setup_spec,
channel_value_present,
stringify_channel_value,
)
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
from nanobot.config.loader import merge_missing_defaults
from nanobot.config.schema import Config
@@ -35,6 +48,8 @@ class InstallResult:
_INSTALL_TIMEOUT_SECONDS = 300
_LOG_OUTPUT_LIMIT = 4000
_HIDDEN_OPTIONAL_FEATURES = {"documents", "langsmith", "pdf"}
_BUNDLED_FEATURE_ALIASES = {"documents", "pdf"}
def load_pyproject(path: Path) -> dict[str, Any]:
@@ -78,9 +93,13 @@ def optional_dependency_groups() -> dict[str, list[str] | None]:
return {
name: list(values)
for name, values in deps.items()
if name != "dev" and isinstance(values, list)
if name != "dev" and name not in _HIDDEN_OPTIONAL_FEATURES and isinstance(values, list)
}
return optional_dependency_groups_from_metadata()
return {
name: values
for name, values in optional_dependency_groups_from_metadata().items()
if name not in _HIDDEN_OPTIONAL_FEATURES
}
def _install_requirements_for_extra(extra: str, deps: list[str]) -> list[str]:
@@ -260,16 +279,6 @@ def write_config_data(path: Path, data: dict[str, Any]) -> None:
json.dump(data, f, indent=2, ensure_ascii=False)
def merge_missing_defaults(existing: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
merged = dict(defaults)
for key, value in existing.items():
if isinstance(value, dict) and isinstance(merged.get(key), dict):
merged[key] = merge_missing_defaults(value, merged[key])
else:
merged[key] = value
return merged
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
@@ -282,6 +291,21 @@ def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[s
write_config_data(config_path, data)
def enable_feishu_instance_config(
config_path: Path,
defaults: dict[str, Any],
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get("feishu", {})
if not isinstance(existing, dict):
existing = {}
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, True)
write_config_data(config_path, data)
def disable_channel_config(config_path: Path, channel_name: str) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
@@ -293,8 +317,27 @@ def disable_channel_config(config_path: Path, channel_name: str) -> None:
write_config_data(config_path, data)
def disable_feishu_instance_config(
config_path: Path,
defaults: dict[str, Any],
*,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> None:
data = read_config_data(config_path)
channels = data.setdefault("channels", {})
existing = channels.get("feishu", {})
if not isinstance(existing, dict):
existing = {}
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, False)
write_config_data(config_path, data)
def channel_enabled(config: Config, name: str) -> bool:
section = getattr(config.channels, name, None)
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return bool(feishu_instance_specs(section, FeishuChannel.default_config(), enabled_only=True))
default_enabled = name in DEFAULT_ENABLED_CHANNELS
if section is None:
return default_enabled
@@ -303,6 +346,97 @@ def channel_enabled(config: Config, name: str) -> bool:
return bool(getattr(section, "enabled", default_enabled))
def _channel_config_snapshot(section: Any, name: str) -> tuple[dict[str, str], list[str]]:
if hasattr(section, "model_dump"):
section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict):
return {}, []
spec = channel_setup_spec(name)
if spec is None:
return {}, []
values: dict[str, str] = {}
configured_fields: list[str] = []
for field in spec.snapshot_fields:
value = channel_field_value(section, field)
if not channel_value_present(value):
continue
key = f"channels.{name}.{field}"
configured_fields.append(key)
if field in spec.secrets:
continue
values[key] = stringify_channel_value(value)
return values, configured_fields
def _channel_has_required_setup(section: Any, name: str) -> bool:
spec = channel_setup_spec(name)
return bool(spec and spec.is_configured(section))
def _local_login_state_present(section: Any, name: str) -> bool:
"""Return whether a QR-login channel has reusable local account state."""
from nanobot.config.loader import get_config_path
if name == "weixin":
configured_dir = channel_field_value(section, "stateDir")
state_dir = (
Path(str(configured_dir)).expanduser()
if configured_dir
else get_config_path().parent / "weixin"
)
try:
payload = json.loads((state_dir / "account.json").read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return False
return bool(str(payload.get("token") or "").strip())
if name == "whatsapp":
configured_path = channel_field_value(section, "databasePath")
database_path = (
Path(str(configured_path)).expanduser()
if configured_path
else get_config_path().parent / "whatsapp-auth" / "neonize.db"
)
try:
return database_path.is_file() and database_path.stat().st_size > 0
except OSError:
return False
return False
def _feishu_instance_display_name(config: dict[str, Any]) -> str:
display_name = str(config.get("displayName") or "").strip()
if display_name:
return display_name
local_name = str(config.get("name") or "").strip()
return local_name or "nanobot"
def channel_configured(config: Config, name: str) -> bool:
"""Return whether a channel has enough saved setup to be enabled directly."""
section = getattr(config.channels, name, None)
if name in {"weixin", "whatsapp"} and _local_login_state_present(section, name):
return True
if section is None:
return False
if name == "feishu":
from nanobot.channels.feishu import FeishuChannel
return any(
_channel_has_required_setup(instance.config, "feishu")
for instance in feishu_instance_specs(section, FeishuChannel.default_config())
)
spec = channel_setup_spec(name)
if not spec or not spec.required:
return channel_enabled(config, name)
return _channel_has_required_setup(section, name)
def optional_features_payload(
*,
config: Config | None = None,
@@ -311,7 +445,14 @@ def optional_features_payload(
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import load_config
config_provided = config is not None
config = config or load_config()
if not config_provided:
with suppress(Exception):
from nanobot.channels.feishu import refresh_saved_feishu_identities
if refresh_saved_feishu_identities(config):
config = load_config()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
plugin_channels = discover_plugins()
@@ -321,21 +462,53 @@ def optional_features_payload(
is_channel = name in builtin_channels or name in plugin_channels
installed = extra_installed(name, extras[name]) if name in extras else True
enabled = channel_enabled(config, name) if is_channel else installed
configured = channel_configured(config, name) if is_channel else installed
ready = bool(enabled and installed)
status = "enabled" if ready else "missing_dependency" if not installed else "not_enabled"
features.append(
{
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": is_channel or name in extras,
}
)
feature = {
"name": name,
"display_name": name.replace("_", " ").title(),
"type": "channel" if is_channel else "feature",
"enabled": enabled,
"configured": configured,
"installed": installed,
"ready": ready,
"status": status,
"install_supported": name in extras or is_channel,
"requires_restart": _feature_requires_restart(name, is_channel=is_channel),
}
if is_channel:
config_values, configured_fields = _channel_config_snapshot(
getattr(config.channels, name, None),
name,
)
if config_values:
feature["config_values"] = config_values
if configured_fields:
feature["configured_fields"] = configured_fields
if name == "feishu" and is_channel:
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(
getattr(config.channels, "feishu", None),
FeishuChannel.default_config(),
)
feature["instances"] = [
{
"id": spec.instance_id,
"name": spec.config.get("name") or "nanobot",
"display_name": _feishu_instance_display_name(spec.config),
"avatar_url": spec.config.get("avatarUrl") or "",
"domain": spec.config.get("domain") or "feishu",
"enabled": bool(spec.config.get("enabled", False)),
"configured": _channel_has_required_setup(spec.config, "feishu"),
"app_id": spec.config.get("appId") or spec.config.get("app_id") or "",
"group_policy": spec.config.get("groupPolicy") or "mention",
"allow_from": list(spec.config.get("allowFrom") or []),
}
for spec in specs
]
features.append(feature)
payload = {
"features": features,
@@ -351,6 +524,7 @@ def enable_optional_feature(
*,
config_path: Path | None = None,
allow_install: bool = True,
instance_id: str = DEFAULT_INSTANCE_ID,
runner: Any = run_install_command,
) -> dict[str, Any]:
from nanobot.channels.registry import (
@@ -360,6 +534,20 @@ def enable_optional_feature(
)
from nanobot.config.loader import get_config_path
# The old extra never powered a runtime integration. Keep the CLI spelling
# as a compatibility alias while directing users to the supported tracer.
if name == "langsmith":
name = "langfuse"
if name in _BUNDLED_FEATURE_ALIASES:
payload = optional_features_payload(
last_action={
"ok": True,
"message": f"Feature '{name}' is included with nanobot",
"enabled": True,
}
)
payload["requires_restart"] = False
return payload
config_path = config_path or get_config_path()
extras = optional_dependency_groups()
builtin_channels = set(discover_channel_names())
@@ -394,7 +582,10 @@ def enable_optional_feature(
f"Channel '{name}' is not importable after enable: {exc}",
status=500,
) from exc
enable_channel_config(config_path, name, channel_cls.default_config())
if name == "feishu":
enable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
else:
enable_channel_config(config_path, name, channel_cls.default_config())
message = f"Enabled channel '{name}'"
elif name in plugin_channels:
enable_channel_config(config_path, name, plugin_channels[name].default_config())
@@ -403,14 +594,26 @@ def enable_optional_feature(
message = f"Enabled feature '{name}'"
payload = optional_features_payload(last_action={"ok": True, "message": message, "enabled": True})
payload["requires_restart"] = bool(name in builtin_channels or name in plugin_channels or name in extras)
payload["requires_restart"] = _feature_requires_restart(
name,
is_channel=name in builtin_channels or name in plugin_channels,
)
return payload
def _feature_requires_restart(name: str, *, is_channel: bool) -> bool:
"""Return whether an installed feature needs the running engine rebuilt."""
if is_channel:
return True
# These libraries are imported lazily or used by a newly spawned service.
return name not in {"api", "documents", "pdf", "olostep"}
def disable_optional_feature(
name: str,
*,
config_path: Path | None = None,
instance_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]:
from nanobot.channels.registry import discover_channel_names, discover_plugins
from nanobot.config.loader import get_config_path
@@ -426,7 +629,13 @@ def disable_optional_feature(
raise OptionalFeatureError(f"Unknown feature: {name}. Available: {available}", status=404)
if name not in known_channels:
raise OptionalFeatureError(f"Feature '{name}' cannot be disabled", status=400)
disable_channel_config(config_path, name)
if name == "feishu":
from nanobot.channels.registry import load_channel_class
channel_cls = load_channel_class(name)
disable_feishu_instance_config(config_path, channel_cls.default_config(), instance_id=instance_id)
else:
disable_channel_config(config_path, name)
payload = optional_features_payload(
last_action={"ok": True, "message": f"Disabled channel '{name}'", "enabled": False}
)
+4
View File
@@ -2,6 +2,7 @@
from nanobot.pairing.store import (
approve_code,
clear_channel,
deny_code,
format_expiry,
format_pairing_reply,
@@ -11,6 +12,7 @@ from nanobot.pairing.store import (
is_approved,
list_pending,
revoke,
revoke_channel,
)
# Metadata keys used by channels and commands to tag pairing-related messages.
@@ -19,6 +21,7 @@ PAIRING_COMMAND_META_KEY = "_pairing_command"
__all__ = [
"approve_code",
"clear_channel",
"deny_code",
"format_expiry",
"format_pairing_reply",
@@ -28,6 +31,7 @@ __all__ = [
"is_approved",
"list_pending",
"revoke",
"revoke_channel",
"PAIRING_CODE_META_KEY",
"PAIRING_COMMAND_META_KEY",
]
+48 -3
View File
@@ -170,7 +170,52 @@ def revoke(channel: str, sender_id: str) -> bool:
_save(data)
logger.info("Revoked {} from {}", sid, channel)
return True
return False
return False
def revoke_channel(channel: str) -> int:
"""Remove all approved sender IDs for *channel*.
Returns the number of approved senders that were removed.
"""
with _LOCK:
data = _load()
approved: dict[str, set[str]] = data.get("approved", {})
users = approved.pop(channel, set())
if not users:
return 0
_save(data)
logger.info("Revoked {} approved sender(s) from {}", len(users), channel)
return len(users)
def clear_channel(channel: str) -> dict[str, int]:
"""Remove approved senders and pending requests for *channel*."""
with _LOCK:
data = _load()
approved: dict[str, set[str]] = data.get("approved", {})
approved_users = approved.pop(channel, set())
pending: dict[str, Any] = data.get("pending", {})
pending_codes = [
code
for code, info in pending.items()
if str(info.get("channel", "")) == channel
]
for code in pending_codes:
del pending[code]
if not approved_users and not pending_codes:
return {"approved": 0, "pending": 0}
_save(data)
logger.info(
"Cleared {} approved sender(s) and {} pending request(s) from {}",
len(approved_users),
len(pending_codes),
channel,
)
return {"approved": len(approved_users), "pending": len(pending_codes)}
def get_approved(channel: str) -> list[str]:
@@ -185,8 +230,8 @@ def format_pairing_reply(code: str) -> str:
return (
"Hi there! This assistant only responds to approved users.\n\n"
f"Your pairing code is: `{code}`\n\n"
"To get access, ask the owner to approve this code:\n"
f"- In this chat: send `/pairing approve {code}`"
"To get access, ask the owner to approve this request in the nanobot WebUI.\n"
f"If the WebUI is not available, the owner can also send `/pairing approve {code}`."
)
+456
View File
@@ -0,0 +1,456 @@
"""Cross-platform lifecycle management for nanobot background processes."""
from __future__ import annotations
import ctypes
import json
import os
import signal
import subprocess
import sys
import tempfile
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from filelock import FileLock
@dataclass(frozen=True)
class ProcessStartOptions:
"""Options shared by managed nanobot processes."""
port: int
verbose: bool = False
workspace: str | None = None
config_path: str | None = None
@dataclass(frozen=True)
class ProcessStatus:
"""Current state of one managed process."""
running: bool
pid: int | None
state_path: Path
log_path: Path
started_at: str | None = None
port: int | None = None
command: tuple[str, ...] = ()
reason: str = "not_started"
@dataclass(frozen=True)
class ProcessResult:
"""Result of a managed process control operation."""
ok: bool
message: str
status: ProcessStatus
@dataclass(frozen=True)
class ProcessRuntimePaths:
"""Filesystem state used to track one managed process."""
run_dir: Path
logs_dir: Path
state_path: Path
log_path: Path
class ManagedProcessRuntime:
"""Manage a detached child process without service-specific policy."""
service_name = "process"
def __init__(
self,
*,
paths: ProcessRuntimePaths,
platform_name: str | None = None,
python_executable: str | None = None,
popen: Callable[..., Any] = subprocess.Popen,
subprocess_run: Callable[..., Any] = subprocess.run,
sleep: Callable[[float], None] = time.sleep,
) -> None:
self.paths = paths
self.platform_name = platform_name or _platform_name()
self.python_executable = python_executable or sys.executable
self._popen = popen
self._subprocess_run = subprocess_run
self._sleep = sleep
@classmethod
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
"""Update a managed state file after the recorded process restarts."""
if not paths.state_path.exists():
return
try:
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
state["pid"] = os.getpid()
runtime = cls(paths=paths)
state["identity"] = runtime._process_identity(os.getpid())
state["started_at"] = _utc_now()
runtime._write_state(state)
def start_background(self, options: ProcessStartOptions) -> ProcessResult:
"""Start the configured command as a detached process."""
with self._lifecycle_lock():
return self._start_background(options)
def _start_background(self, options: ProcessStartOptions) -> ProcessResult:
current = self.status()
if current.running:
return ProcessResult(False, self._message("already_running"), current)
command = self._build_child_command(options)
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
with self.paths.log_path.open("a", encoding="utf-8") as log_handle:
process = self._popen(
command,
stdin=subprocess.DEVNULL,
stdout=log_handle,
stderr=subprocess.STDOUT,
**self._popen_platform_kwargs(),
)
pid = int(process.pid)
self._sleep(0.2)
if not self._is_pid_running(pid):
return ProcessResult(False, self._message("exited_during_startup"), self.status())
self._write_state(
{
"pid": pid,
"identity": self._process_identity(pid),
"started_at": _utc_now(),
"platform": self.platform_name,
"port": options.port,
"workspace": options.workspace,
"config_path": options.config_path,
"command": command,
"log_path": str(self.paths.log_path),
}
)
return ProcessResult(True, self._message("started_background"), self.status())
def stop(self, *, timeout_s: int = 20) -> ProcessResult:
"""Stop the process recorded in this runtime's state file."""
with self._lifecycle_lock():
return self._stop(timeout_s=timeout_s)
def _stop(self, *, timeout_s: int) -> ProcessResult:
status = self.status()
if not status.pid:
return ProcessResult(False, self._message("not_running"), status)
state = self._read_state()
if not self._record_matches_process(state, status.pid):
self._clear_state()
return ProcessResult(
False,
self._message("state_stale"),
self.status(reason="stale_state"),
)
if not self._terminate(status.pid, timeout_s=timeout_s):
final_status = self.status(reason="stop_timeout")
if final_status.running:
return ProcessResult(
False,
self._message("stop_timeout"),
final_status,
)
return ProcessResult(True, self._message("stopped"), final_status)
self._clear_state()
return ProcessResult(True, self._message("stopped"), self.status(reason="stopped"))
def restart(self, options: ProcessStartOptions, *, timeout_s: int = 20) -> ProcessResult:
"""Restart the managed process."""
with self._lifecycle_lock():
stop_result = self._stop(timeout_s=timeout_s)
recoverable = {self._message("not_running"), self._message("state_stale")}
if not stop_result.ok and stop_result.message not in recoverable:
return stop_result
return self._start_background(options)
def status(self, *, reason: str | None = None) -> ProcessStatus:
"""Return live status, clearing stale state when needed."""
state = self._read_state()
pid = _as_int(state.get("pid")) if state else None
if pid is None:
return ProcessStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "not_started",
)
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
self._clear_state()
return ProcessStatus(
running=False,
pid=None,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
reason=reason or "stale_state",
)
command = state.get("command")
return ProcessStatus(
running=True,
pid=pid,
state_path=self.paths.state_path,
log_path=self.paths.log_path,
started_at=_as_str(state.get("started_at")),
port=_as_int(state.get("port")),
command=tuple(command) if isinstance(command, list) else (),
reason=reason or "running",
)
def read_log_tail(self, *, tail: int = 200) -> list[str]:
"""Return the last ``tail`` log lines."""
if tail <= 0 or not self.paths.log_path.exists():
return []
try:
lines = self.paths.log_path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
return lines[-tail:]
def follow_logs(self, *, tail: int = 200) -> int:
"""Print existing log lines and follow new output."""
for line in self.read_log_tail(tail=tail):
print(line)
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
self.paths.log_path.touch(exist_ok=True)
try:
with self.paths.log_path.open("r", encoding="utf-8", errors="replace") as handle:
handle.seek(0, os.SEEK_END)
while True:
line = handle.readline()
if line:
print(line.rstrip("\n"))
else:
self._sleep(0.5)
except KeyboardInterrupt:
return 130
def _message(self, event: str) -> str:
return f"{self.service_name}_{event}"
def _lifecycle_lock(self) -> FileLock:
lock_path = self.paths.state_path.with_name(f"{self.paths.state_path.name}.lock")
return FileLock(str(lock_path))
def _build_child_command(self, options: ProcessStartOptions) -> list[str]:
raise NotImplementedError
def _popen_platform_kwargs(self) -> dict[str, Any]:
if self.platform_name == "Windows":
flags = 0
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
return {"creationflags": flags}
return {"start_new_session": True}
def _terminate(self, pid: int, *, timeout_s: int) -> bool:
if self.platform_name == "Windows":
return self._terminate_windows(pid, timeout_s=timeout_s)
return self._terminate_posix(pid, timeout_s=timeout_s)
def _terminate_posix(self, pid: int, *, timeout_s: int) -> bool:
try:
pgid = os.getpgid(pid)
except OSError:
pgid = None
try:
if pgid is not None:
os.killpg(pgid, signal.SIGTERM)
else:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
return True
if self._wait_for_exit(pid, timeout_s):
return True
with suppress(ProcessLookupError, PermissionError):
if pgid is not None:
os.killpg(pgid, signal.SIGKILL)
else:
os.kill(pid, signal.SIGKILL)
return self._wait_for_exit(pid, 2)
def _terminate_windows(self, pid: int, *, timeout_s: int) -> bool:
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
if ctrl_break is not None:
ctrl_break_sent = False
try:
os.kill(pid, ctrl_break)
except ProcessLookupError:
return True
except OSError:
pass
else:
ctrl_break_sent = True
if ctrl_break_sent and self._wait_for_exit(pid, timeout_s):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if self._wait_for_exit(pid, 2):
return True
self._subprocess_run(
["taskkill", "/PID", str(pid), "/T", "/F"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return self._wait_for_exit(pid, 2)
def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool:
deadline = time.monotonic() + max(float(timeout_s), 0.0)
while time.monotonic() < deadline:
if not self._is_pid_running(pid):
return True
self._sleep(0.1)
return not self._is_pid_running(pid)
def _is_pid_running(self, pid: int) -> bool:
if pid <= 0:
return False
if self.platform_name == "Windows":
return _windows_process_identity(pid) is not None
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def _process_identity(self, pid: int) -> str | int | None:
if self.platform_name == "Windows":
return _windows_process_identity(pid)
try:
return os.getpgid(pid)
except OSError:
return None
def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool:
if not state:
return False
recorded = state.get("identity")
if recorded is None:
return True
return recorded == self._process_identity(pid)
def _read_state(self) -> dict[str, Any] | None:
try:
with self.paths.state_path.open(encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError, ValueError):
return None
return payload if isinstance(payload, dict) else None
def _write_state(self, payload: dict[str, Any]) -> None:
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f"{self.paths.state_path.name}.",
suffix=".tmp",
dir=self.paths.run_dir,
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, indent=2, ensure_ascii=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
tmp_path.replace(self.paths.state_path)
finally:
tmp_path.unlink(missing_ok=True)
def _clear_state(self) -> None:
self.paths.state_path.unlink(missing_ok=True)
def _platform_name() -> str:
if sys.platform.startswith("win"):
return "Windows"
if sys.platform == "darwin":
return "Darwin"
return "Linux"
def _utc_now() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _as_int(value: object) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
return None
return None
def _as_str(value: object) -> str | None:
return value if isinstance(value, str) else None
def _windows_process_identity(pid: int) -> str | None:
if os.name != "nt":
return None
class FileTime(ctypes.Structure):
_fields_ = [("low", ctypes.c_uint32), ("high", ctypes.c_uint32)]
@property
def value(self) -> int:
return (int(self.high) << 32) | int(self.low)
process_query_limited_information = 0x1000
kernel32 = ctypes.windll.kernel32
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
if not handle:
return None
try:
creation_time = FileTime()
exit_time = FileTime()
kernel_time = FileTime()
user_time = FileTime()
ok = kernel32.GetProcessTimes(
handle,
ctypes.byref(creation_time),
ctypes.byref(exit_time),
ctypes.byref(kernel_time),
ctypes.byref(user_time),
)
if not ok:
return None
exit_code = ctypes.c_uint32()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return None
if exit_code.value != 259:
return None
return str(creation_time.value)
finally:
kernel32.CloseHandle(handle)
+54
View File
@@ -18,6 +18,16 @@ from typing import Any
from pydantic.alias_generators import to_snake
@dataclass(frozen=True)
class ProviderModelSpec:
"""A curated model exposed by providers without a model-list endpoint."""
id: str
label: str = ""
description: str = ""
context_window: int | None = None
@dataclass(frozen=True)
class ProviderSpec:
"""One LLM provider's metadata. See PROVIDERS below for real examples.
@@ -33,6 +43,8 @@ class ProviderSpec:
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
display_name: str = "" # shown in `nanobot status`
model_catalog: str = "auto" # WebUI model-list source
builtin_models: tuple[ProviderModelSpec, ...] = ()
settings_alias_for: str = "" # compatibility alias grouped under this provider in Settings
# which provider implementation to use
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
@@ -198,6 +210,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("opencode/", "opencode_zen", "opencode-zen"),
env_key="OPENCODE_API_KEY",
display_name="OpenCode Zen",
settings_alias_for="opencode",
backend="openai_compat",
is_gateway=True,
detect_by_base_keyword="opencode.ai/zen",
@@ -361,6 +374,47 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
keywords=("openai-codex",),
env_key="",
display_name="OpenAI Codex",
model_catalog="builtin",
builtin_models=(
ProviderModelSpec(
id="openai-codex/gpt-5.6-sol",
label="GPT-5.6-Sol",
description="Latest frontier agentic coding model.",
context_window=372000,
),
ProviderModelSpec(
id="openai-codex/gpt-5.6-terra",
label="GPT-5.6-Terra",
description="Balanced agentic coding model for everyday work.",
context_window=372000,
),
ProviderModelSpec(
id="openai-codex/gpt-5.6-luna",
label="GPT-5.6-Luna",
description="Fast and affordable agentic coding model.",
context_window=372000,
),
ProviderModelSpec(
id="openai-codex/gpt-5.5",
label="GPT-5.5",
description="Frontier model for complex coding, research, and real-world work.",
),
ProviderModelSpec(
id="openai-codex/gpt-5.4",
label="GPT-5.4",
description="Strong model for everyday coding.",
),
ProviderModelSpec(
id="openai-codex/gpt-5.4-mini",
label="GPT-5.4-Mini",
description="Small, fast, and cost-efficient model for simpler coding tasks.",
),
ProviderModelSpec(
id="openai-codex/gpt-5.3-codex-spark",
label="GPT-5.3-Codex-Spark",
description="Ultra-fast coding model.",
),
),
backend="openai_codex",
detect_by_base_keyword="codex",
default_api_base="https://chatgpt.com/backend-api",
+12
View File
@@ -29,6 +29,18 @@ _URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
def is_loopback_host(host: str) -> bool:
"""Return whether a bind target is explicitly limited to loopback."""
normalized = host.strip().rstrip(".").lower()
if normalized == "localhost":
return True
if normalized.startswith("[") and normalized.endswith("]"):
normalized = normalized[1:-1]
with suppress(ValueError):
return ipaddress.ip_address(normalized).is_loopback
return False
def configure_ssrf_whitelist(cidrs: list[str]) -> None:
"""Allow specific CIDR ranges to bypass SSRF blocking (e.g. Tailscale's 100.64.0.0/10)."""
global _allowed_networks
+1 -1
View File
@@ -58,7 +58,7 @@ If the user selected `source (git clone)`, ask for the local checkout path:
**Question 2 — Optional dependencies:**
```
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, documents, feishu, matrix, mochat, msteams, napcat, qq, slack, telegram, wecom, weixin, langsmith, pdf"
question: "Which optional dependencies do you need? List names separated by spaces, or reply 'none'. Available: api, azure, bedrock, dingtalk, discord, feishu, langfuse, matrix, mochat, msteams, napcat, olostep, qq, slack, telegram, wecom, weixin"
```
Parse the reply. If the user says "none" or similar, set extras to empty. Otherwise collect the valid names.
+176 -26
View File
@@ -1,7 +1,9 @@
"""Document text extraction utilities for nanobot."""
import mimetypes
from dataclasses import dataclass
from pathlib import Path
from zipfile import BadZipFile, ZipFile
from loguru import logger
@@ -37,6 +39,60 @@ SUPPORTED_EXTENSIONS: set[str] = {
}
_MAX_TEXT_LENGTH = 200_000
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
_MAX_OFFICE_UNCOMPRESSED_SIZE = 256 * 1024 * 1024 # 256 MB
_MAX_OFFICE_MEMBER_SIZE = 128 * 1024 * 1024 # 128 MB
_MAX_PDF_CONTENT_STREAM_SIZE = 32 * 1024 * 1024 # 32 MB per page
_MAX_PDF_ATTACHMENT_PAGES = 100
class _TextCollector:
"""Build bounded parser output without retaining the full document text."""
def __init__(self, limit: int) -> None:
self.limit = limit
self.parts: list[str] = []
self.length = 0
self.truncated = False
def add(self, text: str, *, separator: str = "") -> bool:
if not text:
return True
prefix = separator if self.parts else ""
chunk = prefix + text
remaining = self.limit - self.length
if len(chunk) > remaining:
if remaining > 0:
self.parts.append(chunk[:remaining])
self.length += remaining
self.truncated = True
return False
self.parts.append(chunk)
self.length += len(chunk)
return True
def render(self) -> str:
text = "".join(self.parts)
if self.truncated:
text += f"... (truncated at {self.limit} chars)"
return text
class PdfSafetyError(Exception):
"""Raised when a PDF exceeds a parser safety boundary."""
class PdfPageRangeError(Exception):
"""Raised when a requested PDF page range is invalid."""
@dataclass(frozen=True, slots=True)
class PdfExtraction:
text: str
total_pages: int
start_page: int
end_page: int
def extract_text(path: Path) -> str | None:
@@ -54,12 +110,16 @@ def extract_text(path: Path) -> str | None:
if not path.exists():
return f"[error: file not found: {path}]"
try:
if path.stat().st_size > _MAX_EXTRACT_FILE_SIZE:
return f"[error: file exceeds {_MAX_EXTRACT_FILE_SIZE // (1024 * 1024)} MB limit]"
except OSError as e:
return f"[error: failed to inspect file: {e!s}]"
ext = path.suffix.lower()
# Document formats -- each branch lazily imports its parser so that
# startup does not pay the ~25 MB cost of loading openpyxl /
# python-docx / python-pptx / pypdf up front (see issue #3422).
# Parsers stay lazy even though they are bundled so idle processes do not
# retain their import cost (see issue #3422).
if ext == ".pdf":
return _extract_pdf(path)
elif ext == ".docx":
@@ -81,21 +141,71 @@ def extract_text(path: Path) -> str | None:
def _extract_pdf(path: Path) -> str:
"""Extract text from PDF using pypdf."""
try:
from pypdf import PdfReader
except ImportError:
return "[error: pypdf not installed]"
try:
reader = PdfReader(path)
pages: list[str] = []
for i, page in enumerate(reader.pages, 1):
text = page.extract_text() or ""
pages.append(f"--- Page {i} ---\n{text}")
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
result = extract_pdf_pages(
path,
max_pages=_MAX_PDF_ATTACHMENT_PAGES,
max_chars=_MAX_TEXT_LENGTH,
)
text = result.text
if result.end_page < result.total_pages - 1:
text += f"\n\n(Showing pages 1-{result.end_page + 1} of {result.total_pages}.)"
return text
except Exception as e:
logger.exception("Failed to extract PDF {}", path)
return f"[error: failed to extract PDF: {e!s}]"
def extract_pdf_pages(
path: Path,
*,
pages: str | None = None,
max_pages: int = _MAX_PDF_ATTACHMENT_PAGES,
max_chars: int = _MAX_TEXT_LENGTH,
) -> PdfExtraction:
"""Extract a bounded PDF page range using the bundled pypdf reader."""
from pypdf import PdfReader
reader = PdfReader(path, strict=False)
total_pages = len(reader.pages)
if total_pages == 0:
return PdfExtraction("", 0, 0, -1)
start, end = _parse_pdf_page_range(pages, total_pages)
end = min(end, start + max_pages - 1)
collector = _TextCollector(max_chars)
for index in range(start, end + 1):
page = reader.pages[index]
contents = page.get_contents()
if contents is not None:
stream_size = len(contents.get_data())
if stream_size > _MAX_PDF_CONTENT_STREAM_SIZE:
raise PdfSafetyError(
f"page {index + 1} content stream exceeds "
f"{_MAX_PDF_CONTENT_STREAM_SIZE // (1024 * 1024)} MB limit"
)
text = (page.extract_text() or "").strip()
if text and not collector.add(f"--- Page {index + 1} ---\n{text}", separator="\n\n"):
end = index
break
return PdfExtraction(collector.render(), total_pages, start, end)
def _parse_pdf_page_range(pages: str | None, total_pages: int) -> tuple[int, int]:
if not pages:
return 0, total_pages - 1
values = pages.strip().split("-")
if len(values) not in {1, 2}:
raise PdfPageRangeError(f"invalid page range: {pages}")
try:
start = int(values[0])
end = int(values[-1])
except ValueError as e:
raise PdfPageRangeError(f"invalid page range: {pages}") from e
if start < 1 or end < start or start > total_pages:
raise PdfPageRangeError(f"invalid page range: {pages}")
return start - 1, min(end, total_pages) - 1
def _extract_docx(path: Path) -> str:
"""Extract text from DOCX using python-docx."""
try:
@@ -103,9 +213,15 @@ def _extract_docx(path: Path) -> str:
except ImportError:
return "[error: python-docx not installed]"
try:
if error := _office_archive_error(path):
return error
doc = DocxDocument(path)
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
collector = _TextCollector(_MAX_TEXT_LENGTH)
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
if text and not collector.add(text, separator="\n\n"):
break
return collector.render()
except Exception as e:
logger.exception("Failed to extract DOCX {}", path)
return f"[error: failed to extract DOCX: {e!s}]"
@@ -118,19 +234,27 @@ def _extract_xlsx(path: Path) -> str:
except ImportError:
return "[error: openpyxl not installed]"
try:
if error := _office_archive_error(path):
return error
wb = load_workbook(path, read_only=True, data_only=True)
try:
sheets: list[str] = []
collector = _TextCollector(_MAX_TEXT_LENGTH)
for sheet_name in wb.sheetnames:
ws = wb[sheet_name]
rows: list[str] = []
wrote_header = False
for row in ws.iter_rows(values_only=True):
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
if row_text.strip():
rows.append(row_text)
if rows:
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
if not wrote_header:
if not collector.add(
f"--- Sheet: {sheet_name} ---",
separator="\n\n",
):
return collector.render()
wrote_header = True
if not collector.add(row_text, separator="\n"):
return collector.render()
return collector.render()
finally:
wb.close()
except Exception as e:
@@ -145,15 +269,21 @@ def _extract_pptx(path: Path) -> str:
except ImportError:
return "[error: python-pptx not installed]"
try:
if error := _office_archive_error(path):
return error
prs = PptxPresentation(path)
slides: list[str] = []
collector = _TextCollector(_MAX_TEXT_LENGTH)
for i, slide in enumerate(prs.slides, 1):
slide_text: list[str] = []
for shape in slide.shapes:
_collect_pptx_shape_text(shape, slide_text)
if slide_text:
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
if not collector.add(
f"--- Slide {i} ---\n" + "\n".join(slide_text),
separator="\n\n",
):
break
return collector.render()
except Exception as e:
logger.exception("Failed to extract PPTX {}", path)
return f"[error: failed to extract PPTX: {e!s}]"
@@ -184,6 +314,28 @@ def _collect_pptx_shape_text(shape, out: list[str]) -> None:
out.append(text)
def _office_archive_error(path: Path) -> str | None:
"""Reject oversized or encrypted OOXML containers before parsing XML."""
try:
with ZipFile(path) as archive:
members = archive.infolist()
except (BadZipFile, OSError) as e:
return f"[error: invalid Office document: {e!s}]"
if len(members) > _MAX_OFFICE_ARCHIVE_MEMBERS:
return f"[error: Office document contains too many files ({len(members)})]"
total_size = 0
for member in members:
if member.flag_bits & 0x1:
return "[error: encrypted Office documents are not supported]"
if member.file_size > _MAX_OFFICE_MEMBER_SIZE:
return "[error: Office document contains an oversized internal file]"
total_size += member.file_size
if total_size > _MAX_OFFICE_UNCOMPRESSED_SIZE:
limit_mb = _MAX_OFFICE_UNCOMPRESSED_SIZE / (1024 * 1024)
return f"[error: Office document expands beyond the {limit_mb:g} MB safety limit]"
return None
def _extract_text_file(path: Path) -> str:
"""Extract text from a plain text file."""
try:
@@ -228,8 +380,6 @@ def _is_text_extension(ext: str) -> bool:
# High-level helper: split media into images + extracted document text
# ---------------------------------------------------------------------------
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
def is_image_file(path: str) -> bool:
"""Check whether *path* looks like an image file.
+3 -2
View File
@@ -3,6 +3,7 @@
The ``dist/`` subdirectory holds the production WebUI bundle served by the
gateway. It is shipped inside the published wheel and is rebuilt automatically
by the ``webui-build`` Hatch hook during ``python -m build``. In an editable
source checkout it stays empty until you run ``cd webui && bun run build``
(or use the Vite dev server at ``cd webui && bun run dev``).
source checkout, ``nanobot webui`` and ``nanobot gateway`` detect stale local
frontend sources and can rebuild the bundle before serving it. WebUI developers
can still use the Vite dev server at ``cd webui && bun run dev``.
"""
+296
View File
@@ -0,0 +1,296 @@
"""Helpers for keeping the bundled WebUI build in sync with source checkouts."""
from __future__ import annotations
import os
import shutil
import subprocess
from collections.abc import Callable, Mapping
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
BuildMode = Literal["auto", "prompt", "warn", "skip"]
_SOURCE_TOP_LEVEL_FILES = (
"index.html",
"package.json",
"bun.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"vite.config.ts",
"vite.config.js",
"tailwind.config.ts",
"tailwind.config.js",
"postcss.config.ts",
"postcss.config.js",
"tsconfig.json",
"tsconfig.build.json",
"components.json",
)
_SOURCE_DIRS = ("src", "public")
class WebUIBuildError(RuntimeError):
"""Raised when the local WebUI bundle cannot be built."""
@dataclass(frozen=True)
class WebUIBundleStatus:
"""Freshness status for a source checkout's bundled WebUI assets."""
source_dir: Path
dist_dir: Path
index_html: Path
source_available: bool
dist_available: bool
stale: bool
reason: str
newest_source: Path | None = None
newest_source_mtime_ns: int | None = None
dist_mtime_ns: int | None = None
@property
def needs_build(self) -> bool:
return self.source_available and self.stale
def default_project_root() -> Path:
"""Return the repository root when running from a source checkout."""
return Path(__file__).resolve().parents[2]
def default_webui_source_dir(project_root: Path | None = None) -> Path:
"""Return the conventional frontend source directory for a checkout."""
root = project_root or default_project_root()
return root / "webui"
def default_webui_dist_dir(project_root: Path | None = None) -> Path:
"""Return the bundled WebUI dist directory for the installed package."""
try:
import nanobot.web as web_pkg # type: ignore[import-not-found]
except ImportError:
root = project_root or default_project_root()
return root / "nanobot" / "web" / "dist"
return Path(web_pkg.__file__).resolve().parent / "dist"
def iter_webui_source_files(source_dir: Path) -> list[Path]:
"""Return WebUI source files that should make the production bundle stale."""
files: list[Path] = []
for name in _SOURCE_TOP_LEVEL_FILES:
candidate = source_dir / name
if candidate.is_file():
files.append(candidate)
for dirname in _SOURCE_DIRS:
root = source_dir / dirname
if not root.is_dir():
continue
files.extend(path for path in root.rglob("*") if path.is_file())
return files
def inspect_webui_bundle(
*,
source_dir: Path | None = None,
dist_dir: Path | None = None,
) -> WebUIBundleStatus:
"""Inspect whether a checkout's WebUI source is newer than the bundled dist."""
resolved_source = source_dir or default_webui_source_dir()
resolved_dist = dist_dir or default_webui_dist_dir()
index_html = resolved_dist / "index.html"
if not (resolved_source / "package.json").is_file():
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=False,
dist_available=index_html.is_file(),
stale=False,
reason="no_source",
)
if not index_html.is_file():
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=True,
dist_available=False,
stale=True,
reason="missing_dist",
)
dist_mtime_ns = index_html.stat().st_mtime_ns
newest_source: Path | None = None
newest_source_mtime_ns: int | None = None
for candidate in iter_webui_source_files(resolved_source):
try:
mtime_ns = candidate.stat().st_mtime_ns
except OSError:
continue
if newest_source_mtime_ns is None or mtime_ns > newest_source_mtime_ns:
newest_source = candidate
newest_source_mtime_ns = mtime_ns
if newest_source_mtime_ns is not None and newest_source_mtime_ns > dist_mtime_ns:
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=True,
dist_available=True,
stale=True,
reason="source_newer",
newest_source=newest_source,
newest_source_mtime_ns=newest_source_mtime_ns,
dist_mtime_ns=dist_mtime_ns,
)
return WebUIBundleStatus(
source_dir=resolved_source,
dist_dir=resolved_dist,
index_html=index_html,
source_available=True,
dist_available=True,
stale=False,
reason="fresh",
newest_source=newest_source,
newest_source_mtime_ns=newest_source_mtime_ns,
dist_mtime_ns=dist_mtime_ns,
)
def describe_webui_bundle_status(status: WebUIBundleStatus) -> str:
"""Return a short user-facing freshness message."""
if status.reason == "missing_dist":
return "Bundled WebUI build is missing."
if status.reason == "source_newer":
changed = _display_source_path(status)
return f"WebUI source is newer than the bundled build ({changed})."
if status.reason == "fresh":
return "Bundled WebUI build is up to date."
return "WebUI source tree was not found; using the bundled build."
def build_webui_bundle(
*,
source_dir: Path | None = None,
dist_dir: Path | None = None,
runner: str | None = None,
subprocess_run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
output: Callable[[str], None] | None = None,
) -> WebUIBundleStatus:
"""Install frontend dependencies and build the WebUI bundle."""
resolved_source = source_dir or default_webui_source_dir()
command_runner = runner or pick_webui_build_runner()
if command_runner is None:
raise WebUIBuildError(
"neither `bun` nor `npm` is available on PATH; install one or run "
"`cd webui && bun run build` manually"
)
_emit(output, f"Building bundled WebUI with `{command_runner}`...")
_run_frontend_command(
[command_runner, "install"],
cwd=resolved_source,
subprocess_run=subprocess_run,
)
_run_frontend_command(
[command_runner, "run", "build"],
cwd=resolved_source,
subprocess_run=subprocess_run,
)
return inspect_webui_bundle(source_dir=resolved_source, dist_dir=dist_dir)
def ensure_webui_bundle(
*,
mode: BuildMode,
source_dir: Path | None = None,
dist_dir: Path | None = None,
confirm: Callable[[str], bool] | None = None,
output: Callable[[str], None] | None = None,
runner: str | None = None,
environ: Mapping[str, str] | None = None,
subprocess_run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
) -> WebUIBundleStatus:
"""Ensure or warn about a stale WebUI bundle according to the selected mode."""
env = environ or os.environ
status = inspect_webui_bundle(source_dir=source_dir, dist_dir=dist_dir)
if not status.needs_build:
return status
detail = describe_webui_bundle_status(status)
if env.get("NANOBOT_SKIP_WEBUI_BUILD") == "1" or mode == "skip":
_emit(output, f"Warning: {detail} Skipping WebUI build.")
return status
if mode == "warn":
_emit(
output,
f"Warning: {detail} Run `cd {status.source_dir} && bun run build` "
"to refresh it.",
)
return status
if mode == "prompt":
if confirm is None:
_emit(output, f"Warning: {detail} No interactive confirmation is available.")
return status
message = "Build WebUI now? This runs `cd webui && bun run build`."
if not confirm(message):
_emit(output, "Continuing with the existing bundled WebUI build.")
return status
try:
return build_webui_bundle(
source_dir=status.source_dir,
dist_dir=status.dist_dir,
runner=runner,
subprocess_run=subprocess_run,
output=output,
)
except WebUIBuildError as exc:
raise WebUIBuildError(f"{detail} {exc}") from exc
def pick_webui_build_runner() -> str | None:
"""Pick the frontend package manager used to build the WebUI."""
for candidate in ("bun", "npm"):
if shutil.which(candidate):
return candidate
return None
def _run_frontend_command(
command: list[str],
*,
cwd: Path,
subprocess_run: Callable[..., subprocess.CompletedProcess],
) -> None:
try:
subprocess_run(command, cwd=cwd, check=True)
except subprocess.CalledProcessError as exc:
raise WebUIBuildError(
f"command failed ({exc.returncode}): {' '.join(command)}"
) from exc
except OSError as exc:
raise WebUIBuildError(f"command failed: {' '.join(command)} ({exc})") from exc
def _display_source_path(status: WebUIBundleStatus) -> str:
if status.newest_source is None:
return "source files changed"
with suppress(ValueError):
return str(status.newest_source.relative_to(status.source_dir))
return str(status.newest_source)
def _emit(output: Callable[[str], None] | None, message: str) -> None:
if output is not None:
output(message)
+435
View File
@@ -0,0 +1,435 @@
"""Short-lived WebUI channel connection sessions."""
from __future__ import annotations
import json
import secrets
import time
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
import httpx
from nanobot.channels import feishu
from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID, validate_instance_id
from nanobot.config.loader import load_config
class ChannelConnectError(Exception):
"""User-facing channel connect failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(slots=True)
class FeishuConnectSession:
id: str
instance_id: str
instance_name: str
device_code: str
qr_url: str
domain: str
interval: int
expire_in: int
created_wall: float
deadline: float
last_error: str | None = None
class FeishuConnectStore:
"""In-memory Feishu/Lark QR connection state.
Sessions intentionally live only in the gateway process and expire quickly.
The app secret is never returned to the browser; it is saved directly to
config when Feishu/Lark completes authorization.
"""
def __init__(self) -> None:
self._sessions: dict[str, FeishuConnectSession] = {}
def start(
self,
*,
domain: str = "feishu",
instance_id: str = DEFAULT_INSTANCE_ID,
mode: str = "replace",
) -> dict[str, Any]:
domain = _normalize_domain(domain)
instance_id = _resolve_instance_id(instance_id, mode)
self._cleanup()
try:
feishu._init_registration(domain)
begin = feishu._begin_registration(domain)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
raise ChannelConnectError(
f"Unable to start Feishu/Lark connection: {exc}",
status=502,
) from exc
session_id = secrets.token_urlsafe(18)
now_wall = time.time()
now = time.monotonic()
expire_in = int(begin["expire_in"])
interval = max(2, int(begin["interval"]))
session = FeishuConnectSession(
id=session_id,
instance_id=instance_id,
instance_name=_default_instance_name(instance_id),
device_code=str(begin["device_code"]),
qr_url=str(begin["qr_url"]),
domain=domain,
interval=interval,
expire_in=expire_in,
created_wall=now_wall,
deadline=now + expire_in,
)
self._sessions[session_id] = session
return _start_payload(session)
def poll(self, session_id: str) -> dict[str, Any]:
self._cleanup()
session = self._sessions.get(session_id)
if session is None:
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
if time.monotonic() >= session.deadline:
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"status": "expired",
"message": "This Feishu connection has expired. Start again.",
}
try:
result = feishu.poll_registration_once(
device_code=session.device_code,
domain=session.domain,
)
except (RuntimeError, OSError, json.JSONDecodeError, httpx.HTTPError) as exc:
session.last_error = str(exc)
return _pending_payload(session)
session.domain = str(result.get("domain") or session.domain)
status = result.get("status")
if status == "succeeded":
feishu.save_registration_result(
result,
instance_id=session.instance_id,
name=session.instance_name,
)
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "succeeded",
"message": "Feishu is connected.",
"domain": session.domain,
"app_id": result.get("app_id"),
}
if status == "failed":
self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id,
"status": "failed",
"message": "Authorization was cancelled or expired.",
"domain": session.domain,
}
return _pending_payload(session)
def cancel(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(session_id, None)
return {
"session_id": session_id,
"instance_id": session.instance_id if session else DEFAULT_INSTANCE_ID,
"status": "cancelled",
"message": "Feishu connection cancelled.",
}
def _cleanup(self) -> None:
now = time.monotonic()
expired = [session_id for session_id, session in self._sessions.items() if now >= session.deadline]
for session_id in expired:
self._sessions.pop(session_id, None)
def _normalize_domain(domain: str) -> str:
normalized = domain.strip().lower()
return normalized if normalized in {"feishu", "lark"} else "feishu"
def _resolve_instance_id(instance_id: str, mode: str) -> str:
if mode == "create":
return f"assistant-{secrets.token_hex(3)}"
try:
return validate_instance_id(instance_id or DEFAULT_INSTANCE_ID)
except ValueError as exc:
raise ChannelConnectError(str(exc), status=400) from exc
def _default_instance_name(instance_id: str) -> str:
return "nanobot" if instance_id == DEFAULT_INSTANCE_ID else f"nanobot {instance_id}"
def _start_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"qr_url": session.qr_url,
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Scan with Feishu or Lark to connect.",
}
def _pending_payload(session: FeishuConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"instance_id": session.instance_id,
"status": "pending",
"domain": session.domain,
"interval_ms": session.interval * 1000,
"expires_at_ms": int((session.created_wall + session.expire_in) * 1000),
"message": "Waiting for authorization.",
}
@dataclass(slots=True)
class WeixinConnectSession:
id: str
qrcode_id: str
qr_url: str
channel: Any
current_poll_base_url: str
refresh_count: int
created_wall: float
deadline: float
last_error: str | None = None
class WeixinConnectStore:
"""In-memory WeChat QR login sessions for the WebUI.
WeChat login writes local account state only after scan confirmation. A
cancelled or expired browser flow leaves any existing account state intact.
"""
def __init__(self) -> None:
self._sessions: dict[str, WeixinConnectSession] = {}
async def start(self, *, force: bool = False) -> dict[str, Any]:
await self._cleanup()
channel = self._build_channel()
if force:
# Start a fresh login flow without touching the currently working
# account. A confirmed scan replaces it via _save_state;
# cancellation or expiry must leave the old account usable.
channel._token = ""
channel._get_updates_buf = ""
elif channel._load_state():
return {
"session_id": "",
"status": "succeeded",
"message": "WeChat is already connected.",
"interval_ms": 2000,
}
channel._client = httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=30),
follow_redirects=True,
)
channel._running = True
try:
qrcode_id, qr_url = await channel._fetch_qr_code()
except Exception as exc:
await self._close_channel(channel)
raise ChannelConnectError(f"Unable to start WeChat QR login: {exc}", status=502) from exc
session_id = secrets.token_urlsafe(18)
now_wall = time.time()
self._sessions[session_id] = WeixinConnectSession(
id=session_id,
qrcode_id=qrcode_id,
qr_url=qr_url,
channel=channel,
current_poll_base_url=channel.config.base_url,
refresh_count=0,
created_wall=now_wall,
deadline=time.monotonic() + 600,
)
return self._start_payload(self._sessions[session_id])
async def poll(self, session_id: str) -> dict[str, Any]:
await self._cleanup()
session = self._sessions.get(session_id)
if session is None:
return {
"session_id": session_id,
"status": "expired",
"message": "This WeChat login has expired. Start again.",
}
try:
status_data = await session.channel._api_get_with_base(
base_url=session.current_poll_base_url,
endpoint="ilink/bot/get_qrcode_status",
params={"qrcode": session.qrcode_id},
auth=False,
)
except Exception as exc:
if session.channel._is_retryable_qr_poll_error(exc):
session.last_error = str(exc)
return self._pending_payload(session)
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": f"WeChat QR login failed: {exc}",
}
if not isinstance(status_data, dict):
return self._pending_payload(session)
status = status_data.get("status", "")
if status == "confirmed":
token = str(status_data.get("bot_token", "") or "")
if not token:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": "WeChat confirmed the scan but returned no token.",
}
base_url = str(status_data.get("baseurl", "") or "")
session.channel._token = token
if base_url:
session.channel.config.base_url = base_url
session.channel._save_state()
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "succeeded",
"message": "WeChat is connected.",
"account": str(status_data.get("ilink_user_id", "") or ""),
}
if status == "scaned_but_redirect":
redirect_host = str(status_data.get("redirect_host", "") or "").strip()
if redirect_host:
redirected_base = (
redirect_host
if redirect_host.startswith(("http://", "https://"))
else f"https://{redirect_host}"
)
session.current_poll_base_url = redirected_base
return self._pending_payload(session)
if status == "expired":
from nanobot.channels.weixin import MAX_QR_REFRESH_COUNT
session.refresh_count += 1
if session.refresh_count > MAX_QR_REFRESH_COUNT:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "expired",
"message": "This WeChat QR code expired. Start again.",
}
try:
session.qrcode_id, session.qr_url = await session.channel._fetch_qr_code()
except Exception as exc:
self._sessions.pop(session_id, None)
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "failed",
"message": f"Could not refresh WeChat QR code: {exc}",
}
session.current_poll_base_url = session.channel.config.base_url
return self._pending_payload(session)
return self._pending_payload(session)
async def cancel(self, session_id: str) -> dict[str, Any]:
session = self._sessions.pop(session_id, None)
if session is not None:
await self._close_channel(session.channel)
return {
"session_id": session_id,
"status": "cancelled",
"message": "WeChat login cancelled.",
}
async def _cleanup(self) -> None:
now = time.monotonic()
expired = [
session_id
for session_id, session in self._sessions.items()
if now >= session.deadline
]
for session_id in expired:
session = self._sessions.pop(session_id, None)
if session is not None:
await self._close_channel(session.channel)
@staticmethod
def _build_channel() -> Any:
from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin import WeixinChannel
section = getattr(load_config().channels, "weixin", None)
if hasattr(section, "model_dump"):
config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
config = dict(section)
else:
config = {}
return WeixinChannel(config, MessageBus())
@staticmethod
async def _close_channel(channel: Any) -> None:
channel._running = False
client = getattr(channel, "_client", None)
if client is not None:
with suppress(Exception):
await client.aclose()
channel._client = None
@staticmethod
def _start_payload(session: WeixinConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"status": "pending",
"qr_url": session.qr_url,
"interval_ms": 2000,
"expires_at_ms": int((session.created_wall + 600) * 1000),
"message": "Scan with WeChat to connect.",
}
@staticmethod
def _pending_payload(session: WeixinConnectSession) -> dict[str, Any]:
return {
"session_id": session.id,
"status": "pending",
"qr_url": session.qr_url,
"interval_ms": 2000,
"expires_at_ms": int((session.created_wall + 600) * 1000),
"message": "Waiting for WeChat scan.",
}
+541
View File
@@ -0,0 +1,541 @@
"""Best-effort Channel setup validation for the WebUI.
Validation is intentionally non-authoritative: it helps the UI explain whether a
channel looks ready, but it never writes config and it does not replace runtime
channel startup semantics.
"""
from __future__ import annotations
import re
import socket
import ssl
from datetime import UTC, datetime
from typing import Any
import httpx
from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import load_config
from nanobot.security.network import resolve_url_target
CheckStatus = str
SetupStatus = str
_TIMEOUT_SECONDS = 4.0
def _official_action(name: str) -> str | None:
spec = channel_setup_spec(name)
return spec.official_url if spec is not None else None
def validate_channel_config(
name: str,
raw_values: dict[str, Any] | None = None,
*,
instance_id: str = "default",
) -> dict[str, Any]:
"""Validate a channel setup without mutating persisted config."""
channel = (name or "").strip()
if not channel:
return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")])
config = load_config()
section = getattr(config.channels, channel, None)
values = _channel_config(channel, section, instance_id=instance_id)
values = _merge_form_values(channel, values, raw_values or {})
validator = _VALIDATORS.get(channel, _validate_generic)
if channel == "email":
payload = _validate_email(
channel,
values,
allow_loopback=config.tools.webui_allow_local_service_access,
)
else:
payload = validator(channel, values)
payload["name"] = channel
return payload
def _validate_websocket(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks = [
_check(
"managed",
"Managed by WebUI",
"pass",
"The browser workbench prepares the local WebSocket channel.",
action_url=_official_action(name),
)
]
return _payload(name, "connected" if _enabled(values) else "configured", checks, can_enable=True)
def _validate_telegram(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
token = _str(values.get("token"))
if token:
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
checks.append(_check("token_format", "Token format", "fail", "Telegram tokens look like 123456:ABC..."))
else:
checks.append(_check("token_format", "Token format", "pass", "Looks like a BotFather token."))
try:
data = _http_get(f"https://api.telegram.org/bot{token}/getMe")
if data.get("ok") and isinstance(data.get("result"), dict):
bot = data["result"]
identity = {
"name": bot.get("username") or bot.get("first_name"),
"account": str(bot.get("id") or ""),
}
checks.append(_check("get_me", "Bot identity", "pass", "Telegram accepted the bot token."))
return _payload(name, "connected", checks, identity=identity, missing_fields=missing)
checks.append(_check("get_me", "Bot identity", "fail", _message_from_response(data, "Telegram rejected the token.")))
except httpx.HTTPStatusError as exc:
checks.append(
_check(
"get_me",
"Bot identity",
"warn",
f"Telegram could not verify the token: HTTP {exc.response.status_code}.",
)
)
except Exception:
checks.append(
_check(
"get_me",
"Bot identity",
"warn",
"Could not reach Telegram now. Try again later.",
)
)
return _status_from_checks(name, checks, missing)
def _validate_discord(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
token = _str(values.get("token"))
if token:
try:
data = _http_get(
"https://discord.com/api/v10/users/@me",
headers={"Authorization": f"Bot {token}"},
)
bot_id = str(data.get("id") or "")
checks.append(_check("bot_token", "Bot token", "pass", "Discord accepted the bot token."))
identity = {
"name": data.get("global_name") or data.get("username"),
"account": bot_id,
}
if bot_id:
checks.append(
_check(
"invite",
"Server invite",
"pass",
"Use this generated OAuth URL to invite the bot.",
action_url=(
"https://discord.com/oauth2/authorize"
f"?client_id={bot_id}&scope=bot%20applications.commands"
),
)
)
return _payload(name, "connected", checks, identity=identity, missing_fields=missing)
except httpx.HTTPStatusError as exc:
checks.append(_check("bot_token", "Bot token", "fail", f"Discord rejected the token: HTTP {exc.response.status_code}"))
except Exception as exc:
checks.append(_check("bot_token", "Bot token", "warn", f"Could not reach Discord now: {exc}"))
return _status_from_checks(name, checks, missing)
def _validate_slack(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
app_token = _str(values.get("appToken"))
bot_token = _str(values.get("botToken"))
if app_token:
checks.append(
_check(
"app_token_prefix",
"Socket Mode app token",
"pass" if app_token.startswith("xapp-") else "fail",
"App-level Socket Mode tokens start with xapp-.",
action_url=_official_action(name),
)
)
if bot_token:
checks.append(
_check(
"bot_token_prefix",
"Bot token",
"pass" if bot_token.startswith("xoxb-") else "fail",
"Bot tokens start with xoxb- after installing the Slack app.",
action_url=_official_action(name),
)
)
if bot_token.startswith("xoxb-"):
try:
data = _http_post(
"https://slack.com/api/auth.test",
headers={"Authorization": f"Bearer {bot_token}"},
)
if data.get("ok"):
identity = {
"name": data.get("user"),
"workspace": data.get("team"),
"account": data.get("user_id"),
}
checks.append(_check("auth_test", "Workspace identity", "pass", "Slack accepted the bot token."))
status = "connected" if app_token.startswith("xapp-") else "configured"
return _payload(name, status, checks, identity=identity, missing_fields=missing)
checks.append(_check("auth_test", "Workspace identity", "fail", _message_from_response(data, "Slack rejected the bot token.")))
except Exception as exc:
checks.append(_check("auth_test", "Workspace identity", "warn", f"Could not reach Slack now: {exc}"))
return _status_from_checks(name, checks, missing)
def _validate_email(
name: str,
values: dict[str, Any],
*,
allow_loopback: bool = False,
) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
if _truthy(values.get("consentGranted")):
checks.append(_check("consent", "Mailbox consent", "pass", "Consent is enabled for this mailbox."))
else:
checks.append(_check("consent", "Mailbox consent", "fail", "Grant consent before nanobot reads this mailbox."))
for prefix, default_port in (("imap", 993), ("smtp", 587)):
host = _str(values.get(f"{prefix}Host"))
port = _int(values.get(f"{prefix}Port")) or default_port
if not host:
continue
if port <= 0 or port > 65535:
checks.append(_check(f"{prefix}_port", f"{prefix.upper()} port", "fail", "Port must be between 1 and 65535."))
continue
checks.append(_check(f"{prefix}_settings", f"{prefix.upper()} settings", "pass", f"{host}:{port} is set."))
try:
_probe_tcp(host, port, allow_loopback=allow_loopback)
checks.append(_check(f"{prefix}_reachability", f"{prefix.upper()} reachability", "pass", "The server accepted a TCP connection."))
except Exception as exc:
checks.append(_check(f"{prefix}_reachability", f"{prefix.upper()} reachability", "warn", f"Could not verify network reachability now: {exc}"))
identity = {"account": _str(values.get("fromAddress") or values.get("imapUsername") or values.get("smtpUsername"))}
return _status_from_checks(name, checks, missing, identity=identity)
def _validate_feishu(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
display_name = _str(values.get("displayName") or values.get("name"))
avatar_url = _str(values.get("avatarUrl"))
if _str(values.get("appId")).startswith(("cli_", "oapi_")):
checks.append(_check("app_id", "App ID", "pass", "A Feishu/Lark App ID is saved."))
elif _str(values.get("appId")):
checks.append(_check("app_id", "App ID", "warn", "App ID is saved, but it does not look like a standard Feishu App ID."))
status = "connected" if not missing else "needs_setup"
identity = {
"name": display_name or "Feishu assistant",
"avatar_url": avatar_url or None,
"account": _str(values.get("appId")),
}
return _payload(name, status, checks, identity=identity, missing_fields=missing)
def _validate_matrix(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
password = _str(values.get("password"))
access_token = _str(values.get("accessToken"))
device_id = _str(values.get("deviceId"))
if password:
checks.append(_check("login", "Login credentials", "pass", "Password login is configured."))
elif access_token and device_id:
checks.append(
_check(
"login",
"Login credentials",
"pass",
"Access token login is configured with its device ID.",
)
)
else:
if not password and not access_token:
missing.append("password_or_accessToken")
message = "Add a password, or an access token with its device ID."
else:
missing.append("deviceId")
message = "A device ID is required with an access token."
checks.append(_check("login", "Login credentials", "fail", message))
checks.append(
_check(
"manual_review",
"Matrix account",
"skipped",
"Room access is verified when the channel starts.",
)
)
return _status_from_checks(name, checks, list(dict.fromkeys(missing)))
def _validate_cli_handoff(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks: list[dict[str, Any]] = []
if _enabled(values) or _str(values.get("token")) or _str(values.get("databasePath")):
checks.append(_check("local_state", "Local login state", "pass", "Saved local login state was detected."))
return _payload(name, "configured", checks, can_enable=True)
checks.append(
_check(
"terminal_login",
"Terminal login",
"skipped",
"This channel uses a terminal QR login flow.",
action_url=_official_action(name),
)
)
return _payload(name, "needs_setup", checks, missing_fields=["terminal_login"], can_enable=False)
def _validate_generic(name: str, values: dict[str, Any]) -> dict[str, Any]:
checks, missing = _required_checks(name, values)
spec = channel_setup_spec(name)
if spec is not None and spec.required:
checks.append(_check("manual_review", "Manual setup", "skipped", "This channel can be checked from saved fields, but not fully verified in-browser."))
return _status_from_checks(name, checks, missing)
if _enabled(values):
return _payload(name, "configured", [_check("enabled", "Enabled", "pass", "This channel is enabled.")])
return _payload(name, "unsupported", [_check("support", "WebUI setup", "skipped", "This channel is not configurable from the WebUI yet.")])
_VALIDATORS = {
"websocket": _validate_websocket,
"telegram": _validate_telegram,
"discord": _validate_discord,
"slack": _validate_slack,
"email": _validate_email,
"feishu": _validate_feishu,
"matrix": _validate_matrix,
"whatsapp": _validate_cli_handoff,
"weixin": _validate_cli_handoff,
}
def _channel_config(name: str, section: Any, *, instance_id: str) -> dict[str, Any]:
if name == "feishu":
try:
from nanobot.channels._feishu_instances import feishu_instance_specs
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(section, FeishuChannel.default_config())
selected = next((spec for spec in specs if spec.instance_id == instance_id), None)
return dict(selected.config) if selected is not None else {}
except Exception:
return {}
if hasattr(section, "model_dump"):
return dict(section.model_dump(mode="json", by_alias=True))
if isinstance(section, dict):
return dict(section)
return {}
def _merge_form_values(
name: str,
values: dict[str, Any],
raw_values: dict[str, Any],
) -> dict[str, Any]:
merged = dict(values)
prefix = f"channels.{name}."
spec = channel_setup_spec(name)
secrets = spec.secrets if spec is not None else frozenset()
for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key:
continue
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
if field in secrets and not _str(raw_value):
continue
_assign(merged, field, raw_value)
return merged
def _required_checks(name: str, values: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]:
checks: list[dict[str, Any]] = []
missing: list[str] = []
spec = channel_setup_spec(name)
for field in spec.simple_required_fields if spec is not None else ():
value = _get(values, field)
if field == "consentGranted":
if not _truthy(value):
missing.append(field)
continue
if _str(value):
checks.append(_check(f"field:{field}", _label(field), "pass", "Configured."))
else:
missing.append(field)
checks.append(_check(f"field:{field}", _label(field), "fail", "Required."))
return checks, missing
def _status_from_checks(
name: str,
checks: list[dict[str, Any]],
missing: list[str],
*,
identity: dict[str, Any] | None = None,
) -> dict[str, Any]:
if missing:
return _payload(name, "needs_setup", checks, identity=identity, missing_fields=missing, can_enable=False)
if any(check["status"] == "fail" for check in checks):
return _payload(name, "invalid", checks, identity=identity, missing_fields=missing, can_enable=False)
if any(check["status"] == "warn" for check in checks) or any(check["status"] == "skipped" for check in checks):
return _payload(name, "configured", checks, identity=identity, missing_fields=missing)
return _payload(name, "connected", checks, identity=identity, missing_fields=missing)
def _payload(
name: str,
status: SetupStatus,
checks: list[dict[str, Any]],
*,
identity: dict[str, Any] | None = None,
missing_fields: list[str] | None = None,
can_enable: bool | None = None,
) -> dict[str, Any]:
missing = missing_fields or []
return {
"name": name,
"status": status,
"checks": checks,
"identity": {key: value for key, value in (identity or {}).items() if value},
"missing_fields": missing,
"can_enable": status not in {"needs_setup", "invalid", "unsupported"} and not missing
if can_enable is None
else can_enable,
"requires_restart": False,
"checked_at": datetime.now(UTC).isoformat(),
"message": _status_message(status),
}
def _check(
check_id: str,
label: str,
status: CheckStatus,
message: str | None = None,
*,
action_url: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"id": check_id, "label": label, "status": status}
if message:
payload["message"] = message
if action_url:
payload["action_url"] = action_url
return payload
def _assign(values: dict[str, Any], field: str, value: Any) -> None:
target = values
parts = field.split(".")
for part in parts[:-1]:
current = target.get(part)
if not isinstance(current, dict):
current = {}
target[part] = current
target = current
target[parts[-1]] = value
def _get(values: dict[str, Any], field: str) -> Any:
target: Any = values
for part in field.split("."):
if not isinstance(target, dict):
return None
target = target.get(part)
return target
def _str(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
return str(value).strip()
def _int(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _truthy(value: Any) -> bool:
if isinstance(value, bool):
return value
return _str(value).lower() in {"1", "true", "yes", "on", "granted"}
def _enabled(values: dict[str, Any]) -> bool:
return _truthy(values.get("enabled"))
def _label(field: str) -> str:
words = re.sub(r"([a-z])([A-Z])", r"\1 \2", field).replace(".", " ").replace("_", " ")
return words[:1].upper() + words[1:]
def _status_message(status: str) -> str:
return {
"connected": "Connection verified.",
"configured": "Configuration is present, but full verification was not possible.",
"needs_setup": "Required setup is missing.",
"invalid": "Configuration was checked and looks invalid.",
"unsupported": "This channel is not supported by the WebUI setup checker.",
}.get(status, "Channel checked.")
def _message_from_response(data: dict[str, Any], fallback: str) -> str:
error = data.get("error") or data.get("description") or data.get("message")
return str(error) if error else fallback
def _http_get(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]:
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
response = client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]:
with httpx.Client(timeout=_TIMEOUT_SECONDS) as client:
response = client.post(url, headers=headers)
response.raise_for_status()
data = response.json()
return data if isinstance(data, dict) else {}
def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None:
url_host = host if ":" not in host or host.startswith("[") else f"[{host}]"
ok, error, resolved_ips = resolve_url_target(
f"http://{url_host}:{port}/",
allow_loopback=allow_loopback,
)
if not ok:
raise ValueError(error)
context = ssl.create_default_context()
last_error: OSError | None = None
for target_ip in resolved_ips:
try:
with socket.create_connection((target_ip, port), timeout=_TIMEOUT_SECONDS) as sock:
if port in {465, 993, 995}:
with context.wrap_socket(sock, server_hostname=host.strip("[]")):
return
return
except OSError as exc:
last_error = exc
if last_error is not None:
raise last_error
raise OSError(f"Could not resolve {host}")
+2
View File
@@ -47,6 +47,7 @@ def build_gateway_services(
local_trigger_store: Any | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
tokens = GatewayTokenStore()
@@ -77,6 +78,7 @@ def build_gateway_services(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
log=logger,
)
return GatewayServices(
+4 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any
from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID
from nanobot.optional_features import (
OptionalFeatureError,
disable_optional_feature,
@@ -25,10 +26,11 @@ def nanobot_features_action(
allow_install: bool = True,
) -> dict[str, Any]:
name = (query_first(query, "name") or "").strip()
instance_id = (query_first(query, "instance_id") or DEFAULT_INSTANCE_ID).strip()
if not name:
raise OptionalFeatureError("missing feature name")
if action == "enable":
return enable_optional_feature(name, allow_install=allow_install)
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
if action == "disable":
if name == "websocket":
raise OptionalFeatureError(
@@ -36,5 +38,5 @@ def nanobot_features_action(
"Use `nanobot plugins disable websocket` from a terminal if you need to disable it.",
status=400,
)
return disable_optional_feature(name)
return disable_optional_feature(name, instance_id=instance_id)
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
+144 -7
View File
@@ -29,6 +29,7 @@ from nanobot.providers.image_generation import (
image_gen_provider_names,
)
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
from nanobot.security.network import is_loopback_host
from nanobot.security.workspace_access import workspace_sandbox_status
from nanobot.webui.token_usage import token_usage_payload
from nanobot.webui.workspaces import (
@@ -46,6 +47,31 @@ def _version_payload() -> dict[str, Any]:
"current": __version__,
}
_DOCS_STABLE_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:\.post\d+)?$")
_DOCS_LATEST_URL = "https://nanobot.wiki/docs/latest"
def _docs_version(version: str) -> str:
"""Map package versions to the matching public docs path."""
normalized = version.strip()
if _DOCS_STABLE_VERSION_RE.fullmatch(normalized):
return normalized
return "latest"
def _docs_payload() -> dict[str, Any]:
"""Return version-aware documentation links for the WebUI."""
docs_version = _docs_version(__version__)
base_url = f"https://nanobot.wiki/docs/{docs_version}"
return {
"version": docs_version,
"base_url": base_url,
"chat_apps_url": f"{base_url}/getting-started/chat-apps",
"latest_url": _DOCS_LATEST_URL,
}
_RUNTIME_CAPABILITIES = {
"can_restart_engine": False,
"can_pick_folder": False,
@@ -342,6 +368,7 @@ def _provider_settings_row(
"api_base": provider_config.api_base,
"default_api_base": spec.default_api_base or None,
"model_selectable": not spec.is_transcription_only,
"model_catalog": _model_catalog_kind(spec),
}
if oauth_status is not None:
row["oauth_account"] = oauth_status["account"]
@@ -352,6 +379,38 @@ def _provider_settings_row(
return row
def _provider_settings_rows(config: Any, selected_provider: str | None) -> list[dict[str, Any]]:
"""Return one Settings row per provider family while preserving legacy configs."""
aliases: dict[str, list[Any]] = {}
for spec in PROVIDERS:
if spec.settings_alias_for:
aliases.setdefault(spec.settings_alias_for, []).append(spec)
rows: list[dict[str, Any]] = []
for canonical in PROVIDERS:
if canonical.settings_alias_for:
continue
candidates = [canonical, *aliases.get(canonical.name, [])]
chosen = next((spec for spec in candidates if spec.name == selected_provider), None)
if chosen is None:
chosen = next(
(
spec
for spec in candidates
if (provider_config := getattr(config.providers, spec.name, None)) is not None
and _provider_configured_for_settings(spec, provider_config)
),
canonical,
)
provider_config = getattr(config.providers, chosen.name, None)
if provider_config is None:
continue
row = _provider_settings_row(chosen.name, chosen, provider_config)
row["label"] = canonical.label
rows.append(row)
return rows
def _model_catalog_kind(spec: Any) -> str:
catalog = getattr(spec, "model_catalog", "auto")
if catalog != "auto":
@@ -404,20 +463,27 @@ def _model_row_payload(row: Any) -> dict[str, Any] | None:
if not model_id:
return None
label: str | None = None
description: str | None = None
owned_by: str | None = None
if isinstance(row, dict):
raw_label = row.get("display_name") or row.get("label") or row.get("name")
if isinstance(raw_label, str) and raw_label.strip() and raw_label.strip() != model_id:
label = raw_label.strip()
raw_description = row.get("description")
if isinstance(raw_description, str) and raw_description.strip():
description = raw_description.strip()
raw_owner = row.get("owned_by") or row.get("owner") or row.get("organization")
if isinstance(raw_owner, str) and raw_owner.strip():
owned_by = raw_owner.strip()
return {
payload = {
"id": model_id,
"label": label,
"owned_by": owned_by,
"context_window": _model_context_window(row),
}
if description:
payload["description"] = description
return payload
def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
@@ -469,6 +535,24 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
"message": "Model list is not available for this provider. Type a model ID manually.",
}
if catalog_kind == "builtin":
rows = [
{
"id": model.id,
"label": model.label or None,
"description": model.description or None,
"owned_by": spec.label,
"context_window": model.context_window,
}
for model in spec.builtin_models
]
return {
**base_payload,
"status": "available",
"models": rows,
"model_count": len(rows),
}
api_base = _resolve_env_placeholders(provider_config.api_base) or spec.default_api_base
if spec.name == "openai" and not api_base:
api_base = "https://api.openai.com/v1"
@@ -684,12 +768,7 @@ def settings_payload(
spec = find_by_name(effective_preset.provider)
selected_provider = spec.name if spec else provider_name
providers = []
for spec in PROVIDERS:
provider_config = getattr(config.providers, spec.name, None)
if provider_config is None:
continue
providers.append(_provider_settings_row(spec.name, spec, provider_config))
providers = _provider_settings_rows(config, selected_provider)
for provider_key, provider_config in _dynamic_provider_items(config):
providers.append(
_provider_settings_row(
@@ -795,6 +874,20 @@ def settings_payload(
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
},
},
"api": {
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": _mask_secret_hint(config.api.api_key),
},
"observability": {
"provider": "langfuse",
"configured": bool(
os.environ.get("LANGFUSE_SECRET_KEY")
and os.environ.get("LANGFUSE_PUBLIC_KEY")
),
"base_url": os.environ.get("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com",
},
"image_generation": {
"enabled": image_config.enabled,
"provider": image_config.provider,
@@ -850,6 +943,7 @@ def settings_payload(
},
"requires_restart": requires_restart,
"version": _version_payload(),
"docs": _docs_payload(),
}
return decorate_settings_payload(
payload,
@@ -1317,6 +1411,49 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=restart_required)
def update_api_settings(query: QueryParams) -> dict[str, Any]:
"""Update the managed OpenAI-compatible API configuration."""
config = load_config()
api = config.api
host = _query_first(query, "host")
if host is not None:
host = host.strip()
if not host:
raise WebUISettingsError("host is required")
api.host = host
port = _query_first(query, "port")
if port is not None:
try:
parsed_port = int(port)
except ValueError:
raise WebUISettingsError("port must be an integer") from None
if parsed_port < 1 or parsed_port > 65535:
raise WebUISettingsError("port must be between 1 and 65535")
api.port = parsed_port
timeout = _query_first(query, "timeout")
if timeout is not None:
try:
parsed_timeout = float(timeout)
except ValueError:
raise WebUISettingsError("timeout must be a number") from None
if parsed_timeout < 1 or parsed_timeout > 3600:
raise WebUISettingsError("timeout must be between 1 and 3600")
api.timeout = parsed_timeout
api_key = _query_first_alias(query, "api_key", "apiKey")
if api_key is not None:
api.api_key = api_key.strip()
if not is_loopback_host(api.host) and not api.api_key.strip():
raise WebUISettingsError("an API key is required when the API is available on the network")
save_config(config)
return settings_payload()
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
config = load_config()
image_config = config.tools.image_generation
+696 -2
View File
@@ -8,7 +8,9 @@ request mapping and response shaping.
from __future__ import annotations
import asyncio
import inspect
import json
import time
from collections.abc import Callable
from typing import Any
@@ -16,9 +18,22 @@ from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.agent.tools.mcp import request_mcp_reload
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import load_config
from nanobot.optional_features import OptionalFeatureError
from nanobot.channels._setup import channel_setup_spec
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.optional_features import (
OptionalFeatureError,
extra_installed,
optional_dependency_groups,
)
from nanobot.pairing import approve_code, deny_code, list_pending
from nanobot.webui.channel_connect import (
ChannelConnectError,
FeishuConnectStore,
WeixinConnectStore,
)
from nanobot.webui.channel_validation import validate_channel_config
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
from nanobot.webui.http_utils import query_first as _query_first
@@ -34,6 +49,7 @@ from nanobot.webui.settings_api import (
settings_payload,
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_image_generation_settings,
update_model_configuration,
update_network_safety_settings,
@@ -47,6 +63,12 @@ QueryParams = dict[str, list[str]]
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
_SKIP_FIELD = object()
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
@@ -73,6 +95,7 @@ class WebUISettingsRouter:
error_response: Callable[[int, str | None], Response],
runtime_surface: str,
runtime_capabilities: dict[str, Any],
channel_feature_action: Callable[..., Any] | None = None,
) -> None:
self.bus = bus
self.logger = logger
@@ -82,7 +105,10 @@ class WebUISettingsRouter:
self._error_response = error_response
self._runtime_surface = runtime_surface
self._runtime_capabilities = runtime_capabilities
self._channel_feature_action = channel_feature_action
self._restart_sections: set[str] = set()
self._feishu_connect = FeishuConnectStore()
self._weixin_connect = WeixinConnectStore()
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
if path == "/api/settings":
@@ -105,6 +131,12 @@ class WebUISettingsRouter:
return await self._handle_settings_provider_oauth(request, "logout")
if path == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if path == "/api/settings/api-service":
return self._handle_settings_api_service(request)
if path == "/api/settings/api-service/start":
return await self._handle_settings_api_service_start(connection, request)
if path == "/api/settings/api-service/stop":
return await self._handle_settings_api_service_stop(request)
if path == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if path == "/api/settings/transcription/update":
@@ -127,6 +159,28 @@ class WebUISettingsRouter:
return await self._handle_settings_nanobot_features_action(connection, request, "enable")
if path == "/api/settings/nanobot-features/disable":
return await self._handle_settings_nanobot_features_action(connection, request, "disable")
if path == "/api/settings/channels/feishu/connect/start":
return await self._handle_settings_feishu_connect_start(request)
if path == "/api/settings/channels/feishu/connect/poll":
return await self._handle_settings_feishu_connect_poll(connection, request)
if path == "/api/settings/channels/feishu/connect/cancel":
return self._handle_settings_feishu_connect_cancel(request)
if path == "/api/settings/channels/weixin/connect/start":
return await self._handle_settings_weixin_connect_start(connection, request)
if path == "/api/settings/channels/weixin/connect/poll":
return await self._handle_settings_weixin_connect_poll(connection, request)
if path == "/api/settings/channels/weixin/connect/cancel":
return await self._handle_settings_weixin_connect_cancel(request)
if path == "/api/settings/channels/validate":
return await self._handle_settings_channel_validate(request)
if path == "/api/settings/channels/configure":
return await self._handle_settings_channel_configure(connection, request)
if path == "/api/settings/pairing":
return self._handle_settings_pairing(request)
if path == "/api/settings/pairing/approve":
return self._handle_settings_pairing_action(request, "approve")
if path == "/api/settings/pairing/deny":
return self._handle_settings_pairing_action(request, "deny")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
if path == "/api/settings/version-check":
@@ -209,6 +263,46 @@ class WebUISettingsRouter:
return self._unauthorized()
return self._json_response(settings_usage_payload())
def _handle_settings_pairing(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(_pairing_payload())
def _handle_settings_pairing_action(self, request: WsRequest, action: str) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
code = (_query_first(query, "code") or "").strip()
if not code:
return self._error_response(400, "Missing pairing code")
if action == "approve":
result = approve_code(code)
if result is None:
return self._error_response(404, "Pairing code not found or expired")
channel, sender_id = result
return self._json_response(
_pairing_payload({
"ok": True,
"action": "approve",
"message": f"Approved {sender_id} for {channel}",
"channel": channel,
"sender_id": sender_id,
"code": code,
})
)
if not deny_code(code):
return self._error_response(404, "Pairing code not found or expired")
return self._json_response(
_pairing_payload({
"ok": True,
"action": "deny",
"message": f"Denied pairing code {code}",
"code": code,
})
)
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
@@ -283,6 +377,134 @@ class WebUISettingsRouter:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="browser"))
def _handle_settings_api_service(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
return self._json_response(self._api_service_payload())
async def _handle_settings_api_service_start(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
await asyncio.to_thread(
nanobot_features_action,
"enable",
{"name": ["api"]},
allow_install=self._allow_feature_package_install(connection, request),
)
update_api_settings(self._parse_api_service_settings_query(request))
config = load_config()
runtime = self._api_runtime()
options = ApiStartOptions(
host=config.api.host,
port=config.api.port,
workspace=str(config.workspace_path),
config_path=str(get_config_path().expanduser().resolve(strict=False)),
)
current = runtime.status()
result = await asyncio.to_thread(
runtime.restart if current.running else runtime.start_background,
options,
)
if not result.ok:
return self._error_response(500, self._api_runtime_message(result.message))
except (WebUISettingsError, OptionalFeatureError) as e:
return self._error_response(getattr(e, "status", 400), getattr(e, "message", str(e)))
except Exception as e:
self.logger.exception("failed to start managed API service")
return self._error_response(500, str(e))
return self._json_response(self._api_service_payload(last_action="started"))
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
query = self._query(request)
if "api_key" in query or "apiKey" in query:
raise WebUISettingsError("API service API key must be provided in the private header")
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
if not raw:
return query
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("API service settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid API service settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("API service settings payload must be a JSON object")
unknown = set(payload) - {"api_key"}
if unknown:
raise WebUISettingsError("API service settings payload contains an invalid key")
api_key = payload.get("api_key")
if api_key is not None and not isinstance(api_key, str):
raise WebUISettingsError("API service API key must be a string")
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
if api_key is not None:
merged["api_key"] = [api_key]
return merged
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
result = await asyncio.to_thread(self._api_runtime().stop)
except Exception as e:
self.logger.exception("failed to stop managed API service")
return self._error_response(500, str(e))
if not result.ok and result.message != "api_not_running":
return self._error_response(500, self._api_runtime_message(result.message))
return self._json_response(self._api_service_payload(last_action="stopped"))
@staticmethod
def _api_runtime() -> ApiRuntime:
config_path = get_config_path().expanduser().resolve(strict=False)
return ApiRuntime(paths=api_runtime_paths(config_path))
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
config = load_config()
status = self._api_runtime().status()
extras = optional_dependency_groups()
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
payload = {
"installed": extra_installed("api", extras.get("api")),
"running": status.running,
"managed": status.running,
"host": config.api.host,
"port": config.api.port,
"timeout": config.api.timeout,
"api_key_hint": self._masked_secret(config.api.api_key),
"endpoint": f"http://{connect_host}:{config.api.port}/v1",
"command": "nanobot serve",
"log_path": str(status.log_path),
}
if last_action:
payload["last_action"] = last_action
return payload
@staticmethod
def _masked_secret(value: str) -> str | None:
value = value.strip()
if not value:
return None
return f"{value[:3]}...{value[-4:]}" if len(value) > 8 else "configured"
@staticmethod
def _api_runtime_message(message: str) -> str:
known = {
"api_exited_during_startup": "API server exited during startup. Check its log for details.",
"api_stop_timeout": "API server did not stop in time.",
"api_state_stale": "API server state was stale; try starting it again.",
}
if message in known:
return known[message]
if message.startswith("api_"):
return f"API server {message.removeprefix('api_').replace('_', ' ')}"
return message.replace("_", " ")
def _handle_settings_image_generation_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
@@ -378,8 +600,460 @@ class WebUISettingsRouter:
if status >= 500:
self.logger.exception("nanobot feature action '{}' failed", action)
return self._error_response(status, message)
payload = await self._apply_nanobot_feature_runtime_change(
action,
self._query(request),
payload,
)
return self._json_response(self._with_restart_state(payload, section="runtime"))
async def _apply_nanobot_feature_runtime_change(
self,
action: str,
query: QueryParams,
payload: dict[str, Any],
) -> dict[str, Any]:
if self._channel_feature_action is None:
return payload
name = (_query_first(query, "name") or "").strip()
if not name:
return payload
try:
instance_id = (_query_first(query, "instance_id") or "").strip()
runtime_name = name
if name == "feishu" and instance_id and instance_id != "default":
runtime_name = f"feishu.{instance_id}"
result = self._channel_feature_action(action, runtime_name)
if inspect.isawaitable(result):
result = await result
except Exception as exc:
self.logger.exception("failed to apply channel '{}' without restart", name)
return self._feature_runtime_fallback(
payload,
message=f"{name} channel config was saved, but hot reload failed: {exc}",
)
if not isinstance(result, dict) or not result.get("handled"):
return payload
payload = dict(payload)
if result.get("requires_restart"):
payload["requires_restart"] = True
else:
payload["requires_restart"] = False
message = result.get("message")
if isinstance(message, str) and message:
last_action = dict(payload.get("last_action") or {})
previous = last_action.get("message")
if isinstance(previous, str) and previous:
last_action["message"] = f"{previous}. {message}"
else:
last_action["message"] = message
last_action["hot_reload"] = not payload["requires_restart"]
payload["last_action"] = last_action
return payload
@staticmethod
def _feature_runtime_fallback(payload: dict[str, Any], *, message: str) -> dict[str, Any]:
payload = dict(payload)
payload["requires_restart"] = True
last_action = dict(payload.get("last_action") or {})
previous = last_action.get("message")
last_action["message"] = f"{previous}. {message}" if isinstance(previous, str) and previous else message
last_action["hot_reload"] = False
payload["last_action"] = last_action
return payload
async def _handle_settings_channel_configure(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
name = (_query_first(query, "name") or "").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
enable = (_query_first(query, "enable") or "").strip().lower() in {"1", "true", "yes"}
try:
saved = await asyncio.to_thread(
self._save_channel_config_values,
name,
self._parse_channel_values_header(request),
instance_id,
)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to save channel '{}' settings", name)
return self._error_response(500, "failed to save channel settings")
payload: dict[str, Any] = {
"name": name,
"saved": True,
"saved_keys": saved,
}
if not enable:
return self._json_response(payload)
feature_query = {"name": [name]}
if name == "feishu":
feature_query["instance_id"] = [instance_id]
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
feature_query,
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as e:
return self._error_response(e.status, f"Settings saved, but {e.message}")
except Exception as e:
self.logger.exception("failed to enable channel '{}' after settings save", name)
return self._error_response(500, f"Settings saved, but enabling {name} failed: {e}")
features = await self._apply_nanobot_feature_runtime_change(
"enable",
feature_query,
features,
)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return self._json_response(payload)
async def _handle_settings_channel_validate(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
name = (_query_first(query, "name") or "").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
try:
payload = await asyncio.to_thread(
validate_channel_config,
name,
self._parse_channel_values_header(request),
instance_id=instance_id,
)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to validate channel '{}' settings", name)
return self._error_response(500, "failed to validate channel settings")
return self._json_response(payload)
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
if not raw:
return {}
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
raise WebUISettingsError("channel settings payload is too large")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise WebUISettingsError("invalid channel settings payload") from exc
if not isinstance(payload, dict):
raise WebUISettingsError("channel settings payload must be a JSON object")
return payload
def _save_channel_config_values(
self,
name: str,
raw_values: dict[str, Any],
instance_id: str = "default",
) -> list[str]:
if not name:
raise WebUISettingsError("missing channel name")
setup_spec = channel_setup_spec(name)
if setup_spec is None:
raise WebUISettingsError(f"channel '{name}' cannot be configured from WebUI", status=404)
field_types = setup_spec.route_field_types
if not raw_values:
return []
config = load_config()
section = getattr(config.channels, name, None)
if name == "feishu":
from nanobot.channels._feishu_instances import feishu_instance_specs
from nanobot.channels.feishu import FeishuChannel
specs = feishu_instance_specs(section, FeishuChannel.default_config())
selected = next((spec for spec in specs if spec.instance_id == instance_id), None)
channel_config = dict(selected.config) if selected is not None else {}
elif hasattr(section, "model_dump"):
channel_config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict):
channel_config = dict(section)
else:
channel_config = {}
saved: list[str] = []
prefix = f"channels.{name}."
for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key:
raise WebUISettingsError("channel settings payload contains an invalid key")
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
value_type = field_types.get(field)
if value_type is None:
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
value = self._coerce_channel_value(raw_key, raw_value, value_type)
if value is _SKIP_FIELD:
continue
self._assign_channel_config_value(channel_config, field, value)
saved.append(raw_key)
if name == "feishu":
from nanobot.channels._feishu_instances import upsert_feishu_instance
from nanobot.channels.feishu import FeishuChannel
existing = getattr(config.channels, name, None)
channel_config = upsert_feishu_instance(
existing if isinstance(existing, dict) else {},
FeishuChannel.default_config(),
instance_id,
channel_config,
)
setattr(config.channels, name, channel_config)
save_config(config)
return saved
@staticmethod
def _coerce_channel_value(raw_key: str, raw_value: Any, value_type: Any) -> Any:
if isinstance(value_type, tuple):
kind = value_type[0]
allowed = value_type[1]
else:
kind = value_type
allowed = None
if kind in {"string", "secret"}:
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if kind == "secret" and not value:
return _SKIP_FIELD
return value
if kind == "list":
if raw_value is None:
return []
if isinstance(raw_value, str):
return [item.strip() for item in raw_value.split(",") if item.strip()]
if isinstance(raw_value, list):
return [str(item).strip() for item in raw_value if str(item).strip()]
raise WebUISettingsError(f"'{raw_key}' must be a comma-separated list")
if kind == "int":
if raw_value in (None, ""):
return _SKIP_FIELD
try:
return int(raw_value)
except (TypeError, ValueError) as exc:
raise WebUISettingsError(f"'{raw_key}' must be a number") from exc
if kind == "bool":
if isinstance(raw_value, bool):
return raw_value
value = str(raw_value).strip().lower()
if value in {"true", "1", "yes", "on"}:
return True
if value in {"false", "0", "no", "off"}:
return False
raise WebUISettingsError(f"'{raw_key}' must be true or false")
if kind == "enum":
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value)
if not value:
return _SKIP_FIELD
if value not in allowed:
options = ", ".join(sorted(allowed))
raise WebUISettingsError(f"'{raw_key}' must be one of: {options}")
return value
raise WebUISettingsError(f"'{raw_key}' has an unsupported field type")
@staticmethod
def _assign_channel_config_value(channel_config: dict[str, Any], field: str, value: Any) -> None:
target = channel_config
parts = field.split(".")
for part in parts[:-1]:
current = target.get(part)
if not isinstance(current, dict):
current = {}
target[part] = current
target = current
target[parts[-1]] = value
async def _handle_settings_feishu_connect_start(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
domain = (_query_first(query, "domain") or "feishu").strip()
instance_id = (_query_first(query, "instance_id") or "default").strip()
mode = (_query_first(query, "mode") or "replace").strip()
try:
payload = await asyncio.to_thread(
self._feishu_connect.start,
domain=domain,
instance_id=instance_id,
mode=mode,
)
except ChannelConnectError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to start Feishu WebUI connect")
return self._error_response(500, "failed to start Feishu connection")
return self._json_response(payload)
async def _handle_settings_feishu_connect_poll(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing Feishu connect session")
try:
payload = await asyncio.to_thread(self._feishu_connect.poll, session_id)
except Exception:
self.logger.exception("failed to poll Feishu WebUI connect")
return self._error_response(500, "failed to poll Feishu connection")
if payload.get("status") == "succeeded":
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
{
"name": ["feishu"],
"instance_id": [str(payload.get("instance_id") or "default")],
},
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as exc:
features = self._feature_runtime_fallback(
nanobot_features_payload(),
message=f"Feishu connected, but enabling channel support failed: {exc.message}",
)
else:
features = await self._apply_nanobot_feature_runtime_change(
"enable",
{
"name": ["feishu"],
"instance_id": [str(payload.get("instance_id") or "default")],
},
features,
)
payload = dict(payload)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return self._json_response(payload)
def _handle_settings_feishu_connect_cancel(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing Feishu connect session")
return self._json_response(self._feishu_connect.cancel(session_id))
async def _handle_settings_weixin_connect_start(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
force = (_query_first(self._query(request), "force") or "").strip().lower() in {
"1",
"true",
"yes",
}
try:
payload = await self._weixin_connect.start(force=force)
except ChannelConnectError as e:
return self._error_response(e.status, e.message)
except Exception:
self.logger.exception("failed to start WeChat WebUI connect")
return self._error_response(500, "failed to start WeChat connection")
if payload.get("status") == "succeeded":
payload = await self._with_channel_connect_success(
connection,
request,
"weixin",
payload,
)
return self._json_response(payload)
async def _handle_settings_weixin_connect_poll(
self,
connection: Any,
request: WsRequest,
) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing WeChat connect session")
try:
payload = await self._weixin_connect.poll(session_id)
except Exception:
self.logger.exception("failed to poll WeChat WebUI connect")
return self._error_response(500, "failed to poll WeChat connection")
if payload.get("status") == "succeeded":
payload = await self._with_channel_connect_success(
connection,
request,
"weixin",
payload,
)
return self._json_response(payload)
async def _handle_settings_weixin_connect_cancel(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
session_id = (_query_first(self._query(request), "session_id") or "").strip()
if not session_id:
return self._error_response(400, "missing WeChat connect session")
return self._json_response(await self._weixin_connect.cancel(session_id))
async def _with_channel_connect_success(
self,
connection: Any,
request: WsRequest,
channel_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
try:
features = await asyncio.to_thread(
nanobot_features_action,
"enable",
{"name": [channel_name]},
allow_install=self._allow_feature_package_install(connection, request),
)
except OptionalFeatureError as exc:
features = self._feature_runtime_fallback(
nanobot_features_payload(),
message=(
f"{channel_name} connected, but enabling channel support failed: "
f"{exc.message}"
),
)
else:
features = await self._apply_nanobot_feature_runtime_change(
"enable",
{"name": [channel_name]},
features,
)
payload = dict(payload)
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
return payload
def _allow_feature_package_install(self, connection: Any, request: WsRequest) -> bool:
if _is_local_browser_request(connection, request.headers):
return True
@@ -423,3 +1097,23 @@ class WebUISettingsRouter:
return self._json_response({
"updateAvailable": update_info,
})
def _pairing_payload(last_action: dict[str, Any] | None = None) -> dict[str, Any]:
now = time.time()
requests = []
for item in list_pending():
expires_at = float(item.get("expires_at", 0) or 0)
created_at = float(item.get("created_at", 0) or 0)
requests.append({
"code": str(item.get("code", "")),
"channel": str(item.get("channel", "")),
"sender_id": str(item.get("sender_id", "")),
"created_at_ms": int(created_at * 1000) if created_at else None,
"expires_at_ms": int(expires_at * 1000) if expires_at else None,
"expires_in_seconds": max(0, int(expires_at - now)) if expires_at else None,
})
payload: dict[str, Any] = {"requests": requests}
if last_action is not None:
payload["last_action"] = last_action
return payload
+2
View File
@@ -162,6 +162,7 @@ class GatewayHTTPHandler:
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
log: Any = logger,
) -> None:
self.config = config
@@ -194,6 +195,7 @@ class GatewayHTTPHandler:
error_response=_http_error,
runtime_surface=runtime_surface,
runtime_capabilities=self._capabilities,
channel_feature_action=channel_feature_action,
)
def workspace_controls_available(self, connection: Any) -> bool: