mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
418 lines
13 KiB
Python
418 lines
13 KiB
Python
"""Best-effort channel setup validation shared by management surfaces.
|
|
|
|
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, cast
|
|
|
|
import httpx
|
|
|
|
from nanobot.channels._setup import channel_setup_spec
|
|
from nanobot.channels.contracts import (
|
|
ChannelSetupSpec,
|
|
ChannelValidationContext,
|
|
channel_field_value,
|
|
channel_instance_config,
|
|
channel_value_present,
|
|
)
|
|
from nanobot.channels.plugin import ChannelPlugin
|
|
from nanobot.channels.registry import load_channel_plugin
|
|
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_contract(name)
|
|
return spec.official_url if spec is not None else None
|
|
|
|
|
|
def _channel_contract(
|
|
name: str,
|
|
) -> tuple[ChannelPlugin | None, ChannelSetupSpec | None]:
|
|
try:
|
|
plugin = load_channel_plugin(name)
|
|
except ImportError:
|
|
return None, None
|
|
return plugin, channel_setup_spec(name, plugin=plugin)
|
|
|
|
|
|
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)
|
|
plugin, setup_spec = _channel_contract(channel)
|
|
values = _channel_config(
|
|
section,
|
|
plugin=plugin,
|
|
instance_id=instance_id,
|
|
)
|
|
values = _merge_form_values(channel, values, raw_values or {}, setup_spec=setup_spec)
|
|
|
|
if setup_spec is not None and setup_spec.validator is not None:
|
|
context = ChannelValidationContext(
|
|
allow_local_service_access=config.tools.webui_allow_local_service_access,
|
|
)
|
|
custom_payload = setup_spec.validator(values, context)
|
|
if cast(object, custom_payload) is not None:
|
|
payload = dict(custom_payload)
|
|
payload.setdefault("checks", [])
|
|
payload.setdefault("missing_fields", [])
|
|
payload.setdefault("can_enable", payload.get("status") in {"configured", "connected"})
|
|
payload.setdefault("requires_restart", True)
|
|
payload["name"] = channel
|
|
return payload
|
|
|
|
payload = _validate_generic(channel, values)
|
|
payload["name"] = channel
|
|
return payload
|
|
|
|
|
|
def _validate_generic(name: str, values: dict[str, Any]) -> dict[str, Any]:
|
|
_, spec = _channel_contract(name)
|
|
checks, missing = _required_checks(name, values, setup_spec=spec)
|
|
if spec is not None:
|
|
composite_checks, composite_missing = _composite_requirement_checks(spec, values)
|
|
checks.extend(composite_checks)
|
|
missing.extend(composite_missing)
|
|
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, list(dict.fromkeys(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.")])
|
|
|
|
|
|
def _channel_config(
|
|
section: Any,
|
|
*,
|
|
plugin: ChannelPlugin | None,
|
|
instance_id: str,
|
|
) -> dict[str, Any]:
|
|
if plugin is not None:
|
|
return channel_instance_config(plugin, section, instance_id=instance_id)
|
|
if hasattr(section, "model_dump"):
|
|
return dict(section.model_dump(mode="json", by_alias=True))
|
|
if isinstance(section, dict):
|
|
return dict(cast(dict[str, Any], section))
|
|
return {}
|
|
|
|
|
|
def _merge_form_values(
|
|
name: str,
|
|
values: dict[str, Any],
|
|
raw_values: dict[str, Any],
|
|
*,
|
|
setup_spec: ChannelSetupSpec | None = None,
|
|
) -> dict[str, Any]:
|
|
merged = dict(values)
|
|
prefix = f"channels.{name}."
|
|
spec = setup_spec
|
|
secrets: frozenset[str] = spec.secrets if spec is not None else frozenset()
|
|
for raw_key, raw_value in raw_values.items():
|
|
if 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],
|
|
*,
|
|
setup_spec: ChannelSetupSpec | None = None,
|
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
checks: list[dict[str, Any]] = []
|
|
missing: list[str] = []
|
|
spec = setup_spec
|
|
if spec is None:
|
|
_, spec = _channel_contract(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 _composite_requirement_checks(
|
|
setup_spec: ChannelSetupSpec,
|
|
values: dict[str, Any],
|
|
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
checks: list[dict[str, Any]] = []
|
|
missing: list[str] = []
|
|
for index, requirement in enumerate(setup_spec.required):
|
|
if requirement.simple_field is not None or requirement.is_satisfied(values):
|
|
continue
|
|
|
|
alternatives = [
|
|
(
|
|
alternative,
|
|
tuple(
|
|
field
|
|
for field in alternative
|
|
if not channel_value_present(channel_field_value(values, field))
|
|
),
|
|
)
|
|
for alternative in requirement.alternatives
|
|
]
|
|
closest = min(
|
|
alternatives,
|
|
key=lambda candidate: (
|
|
len(candidate[1]),
|
|
-(len(candidate[0]) - len(candidate[1])),
|
|
),
|
|
default=((), ()),
|
|
)[1]
|
|
missing.extend(closest or (f"required_setup_{index}",))
|
|
alternatives_label = " or ".join(
|
|
" + ".join(_label(field) for field in alternative)
|
|
for alternative in requirement.alternatives
|
|
)
|
|
message = (
|
|
f"Complete one of: {alternatives_label}."
|
|
if alternatives_label
|
|
else "Required setup is incomplete."
|
|
)
|
|
checks.append(
|
|
_check(
|
|
f"requirement:{index}",
|
|
"Required setup",
|
|
"fail",
|
|
message,
|
|
)
|
|
)
|
|
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 = cast(dict[str, Any], 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 = cast(dict[str, Any], 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 cast(dict[str, Any], 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 cast(dict[str, Any], 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}")
|
|
|
|
|
|
# Public helpers for channel-owned validators. Keeping response shaping here lets
|
|
# each package own its platform checks without depending on WebUI implementation
|
|
# modules.
|
|
check = _check
|
|
enabled = _enabled
|
|
http_get = _http_get
|
|
http_post = _http_post
|
|
int_value = _int
|
|
message_from_response = _message_from_response
|
|
official_action = _official_action
|
|
payload = _payload
|
|
probe_tcp = _probe_tcp
|
|
required_checks = _required_checks
|
|
status_from_checks = _status_from_checks
|
|
string_value = _str
|
|
truthy = _truthy
|
|
|
|
__all__ = [
|
|
"check",
|
|
"enabled",
|
|
"http_get",
|
|
"http_post",
|
|
"int_value",
|
|
"message_from_response",
|
|
"official_action",
|
|
"payload",
|
|
"probe_tcp",
|
|
"required_checks",
|
|
"status_from_checks",
|
|
"string_value",
|
|
"truthy",
|
|
"validate_channel_config",
|
|
]
|