Files
nanobot/nanobot/webui/ws_http.py
T
chengyongru 8b42d0760e fix(webui): harden automation management API
Maintainer edit: redact external channel chat identifiers from the WebUI automation payload and reject malformed or unschedulable automation updates before they mutate cron jobs.
2026-06-14 22:19:28 +08:00

882 lines
34 KiB
Python

"""HTTP API handler extracted from WebSocketChannel.
Handles all non-WebSocket HTTP routes: bootstrap, sessions, settings,
media, commands, sidebar state, static file serving, and token management.
Also houses shared HTTP utility functions used by both this module and
``websocket.py`` to avoid circular imports.
"""
from __future__ import annotations
import asyncio
import json
import mimetypes
import re
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
from loguru import logger
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.command.builtin import builtin_command_palette
from nanobot.cron.types import CronSchedule
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
from nanobot.webui.http_utils import (
case_insensitive_header as _case_insensitive_header,
)
from nanobot.webui.http_utils import (
host_for_url as _host_for_url,
)
from nanobot.webui.http_utils import (
http_error as _http_error,
)
from nanobot.webui.http_utils import (
http_json_response as _http_json_response,
)
from nanobot.webui.http_utils import (
http_response as _http_response,
)
from nanobot.webui.http_utils import (
is_localhost as _is_localhost,
)
from nanobot.webui.http_utils import (
issue_route_secret_matches as _issue_route_secret_matches,
)
from nanobot.webui.http_utils import (
normalize_config_path as _normalize_config_path,
)
from nanobot.webui.http_utils import (
parse_query as _parse_query,
)
from nanobot.webui.http_utils import (
parse_request_path as _parse_request_path,
)
from nanobot.webui.http_utils import (
query_first as _query_first,
)
from nanobot.webui.http_utils import (
safe_host_header as _safe_host_header,
)
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.session_automations import (
all_automations_payload,
serialize_automation_jobs,
session_automation_jobs,
session_automations_payload,
)
from nanobot.webui.session_list_index import list_webui_sessions
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
)
from nanobot.webui.skills_api import webui_skill_detail_payload, webui_skills_payload
from nanobot.webui.thread_disk import delete_webui_thread
from nanobot.webui.transcript import build_webui_thread_response
from nanobot.webui.workspaces import WebUIWorkspaceController
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
if TYPE_CHECKING:
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager
def _decode_api_key(raw_key: str) -> str | None:
from urllib.parse import unquote
key = unquote(raw_key)
_api_key_re = re.compile(r"^[A-Za-z0-9_:.-]{1,128}$")
if _api_key_re.match(key) is None:
return None
return key
def _default_model_name_from_config() -> str | None:
try:
from nanobot.config.loader import load_config
model = load_config().resolve_preset().model.strip()
return model or None
except Exception as e:
logger.debug("bootstrap model_name could not load from config: {}", e)
return None
def _resolve_bootstrap_model_name(
runtime_name: Callable[[], str | None] | None,
) -> str:
if runtime_name is not None:
try:
raw = runtime_name()
except Exception as e:
logger.debug("bootstrap runtime model resolver failed: {}", e)
else:
if isinstance(raw, str):
stripped = raw.strip()
if stripped:
return stripped
return _default_model_name_from_config() or ""
# ---------------------------------------------------------------------------
# GatewayHTTPHandler
# ---------------------------------------------------------------------------
class GatewayHTTPHandler:
"""Handles all HTTP routes served alongside the WebSocket endpoint.
Routes HTTP requests and delegates stateful work to explicit gateway
services owned by the composition layer.
"""
def __init__(
self,
*,
config: Any, # WebSocketConfig
session_manager: SessionManager | None,
static_dist_path: Path | None,
runtime_model_name: Callable[[], str | None] | None,
runtime_surface: str,
runtime_capabilities_overrides: dict[str, Any] | None,
bus: MessageBus,
tokens: GatewayTokenStore,
media: WebUIMediaGateway,
workspaces: WebUIWorkspaceController,
skills_workspace_path: Path,
disabled_skills: set[str] | None = None,
cron_service: CronService | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
log: Any = logger,
) -> None:
self.config = config
self.session_manager = session_manager
self.static_dist_path = static_dist_path
self.runtime_model_name = runtime_model_name
self.bus = bus
self.tokens = tokens
self.media = media
self.workspaces = workspaces
self.skills_workspace_path = skills_workspace_path
self.disabled_skills = disabled_skills or set()
self.cron_service = cron_service
self.cron_pending_job_ids = cron_pending_job_ids
self._log = log
self._runtime_surface = runtime_surface
from nanobot.webui.settings_api import runtime_capabilities as _rc
from nanobot.webui.settings_routes import WebUISettingsRouter
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
self.settings_routes = WebUISettingsRouter(
bus=bus,
logger=self._log,
check_api_token=self.check_api_token,
parse_query=_parse_query,
json_response=_http_json_response,
error_response=_http_error,
runtime_surface=runtime_surface,
runtime_capabilities=self._capabilities,
)
def workspace_controls_available(self, connection: Any) -> bool:
return self._runtime_surface == "native" or _is_localhost(connection)
# -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool:
return self.tokens.check_api_token(request)
# -- Main dispatch ------------------------------------------------------
async def dispatch(self, connection: Any, request: WsRequest) -> Any | None:
"""Route an HTTP request. Returns Response or None."""
got, _ = _parse_request_path(request.path)
started = time.perf_counter()
response: Any | None = None
try:
response = await self._dispatch_resolved(connection, request, got)
return response
finally:
self._log_slow_http(got, response, started)
async def _dispatch_resolved(
self,
connection: Any,
request: WsRequest,
got: str,
) -> Any | None:
# Token issue endpoint
if self.config.token_issue_path:
issue_expected = _normalize_config_path(self.config.token_issue_path)
if got == issue_expected:
return self._handle_token_issue(connection, request)
# Bootstrap
if got == "/webui/bootstrap":
return self._handle_bootstrap(connection, request)
# Settings routes (delegated)
response = await self.settings_routes.dispatch(request, got)
if response is not None:
return response
# Session routes
response = await self._dispatch_session_routes(request, got)
if response is not None:
return response
# Media routes
response = self._dispatch_media_routes(request, got)
if response is not None:
return response
# Automation routes
response = await self._dispatch_automation_routes(request, got)
if response is not None:
return response
# Misc routes
response = await self._dispatch_misc_routes(connection, request, got)
if response is not None:
return response
# API 404 (never serve SPA for /api/ routes)
if got.startswith("/api/"):
return _http_error(404, "API route not found")
# Static SPA serving
if self.static_dist_path is not None:
response = self._serve_static(got)
if response is not None:
return response
return connection.respond(404, "Not Found")
def _log_slow_http(self, path: str, response: Any | None, started: float) -> None:
elapsed_ms = int((time.perf_counter() - started) * 1000)
if elapsed_ms < _SLOW_WEBUI_HTTP_LOG_MS:
return
if not (path.startswith("/api/") or path == "/webui/bootstrap"):
return
status = getattr(response, "status_code", None)
self._log.warning(
"slow webui http route path={} status={} duration_ms={}",
path,
status if status is not None else "none",
elapsed_ms,
)
# -- Token issue --------------------------------------------------------
def _handle_token_issue(self, connection: Any, request: Any) -> Any:
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return connection.respond(401, "Unauthorized")
else:
self._log.warning(
"token_issue_path is set but token_issue_secret is empty; "
"any client can obtain connection tokens — set token_issue_secret for production."
)
if not self.tokens.can_issue():
self._log.error(
"too many outstanding issued tokens ({}), rejecting issuance",
len(self.tokens.issued_tokens),
)
return _http_json_response({"error": "too many outstanding tokens"}, status=429)
token_value = self.tokens.issue_token(self.config.token_ttl_s)
return _http_json_response(token_response_payload(token_value, self.config.token_ttl_s))
# -- Bootstrap ----------------------------------------------------------
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
if secret:
if not _issue_route_secret_matches(request.headers, secret):
return _http_error(401, "Unauthorized")
elif not _is_localhost(connection):
return _http_error(403, "bootstrap is localhost-only")
if not self.tokens.can_issue(include_api_token=True):
return _http_response(
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
status=429,
content_type="application/json; charset=utf-8",
)
token = self.tokens.issue_token(self.config.token_ttl_s, api_token=True)
ws_url = self._bootstrap_ws_url(request)
expected_path = _normalize_config_path(self.config.path)
return _http_json_response(
{
"token": token,
"ws_path": expected_path,
"ws_url": ws_url,
"expires_in": self.config.token_ttl_s,
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
"runtime_surface": self._runtime_surface,
"runtime_capabilities": self._capabilities,
}
)
def _bootstrap_ws_url(self, request: Any) -> str:
headers = getattr(request, "headers", {}) or {}
host = _safe_host_header(_case_insensitive_header(headers, "Host"))
if not host:
host = _host_for_url(self.config.host, self.config.port)
proto = _case_insensitive_header(headers, "X-Forwarded-Proto")
proto = proto.split(",", 1)[0].strip().lower()
secure = proto in {"https", "wss"} or bool(self.config.ssl_certfile.strip())
scheme = "wss" if secure else "ws"
expected_path = _normalize_config_path(self.config.path)
return f"{scheme}://{host}{expected_path}"
# -- Session routes -----------------------------------------------------
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got)
if m:
return self._handle_webui_thread_get(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/file-preview$", got)
if m:
return self._handle_file_preview(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/automations$", got)
if m:
return self._handle_session_automations(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
if m:
return self._handle_session_delete(request, m.group(1))
return None
async def _handle_sessions_list(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if self.session_manager is None:
return _http_error(503, "session manager unavailable")
payload = await asyncio.to_thread(self._sessions_list_payload)
return _http_json_response(payload)
def _sessions_list_payload(self) -> dict[str, Any]:
assert self.session_manager is not None
sessions = list_webui_sessions(self.session_manager)
from nanobot.session.webui_turns import websocket_turn_wall_started_at
cleaned = []
for s in sessions:
key = s.get("key")
if not (isinstance(key, str) and key.startswith("websocket:")):
continue
row = {k: v for k, v in s.items() if k != "path"}
chat_id = key.split(":", 1)[1]
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
row["run_started_at"] = started_at
scope = self.workspaces.scope_for_session_key(key)
row["workspace_scope"] = scope.payload()
cleaned.append(row)
return {"sessions": cleaned}
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if self.session_manager is None:
return _http_error(503, "session manager unavailable")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
data = self.session_manager.read_session_file(decoded_key)
if data is None:
return _http_error(404, "session not found")
messages = data.get("messages")
if isinstance(messages, list):
scrub_subagent_messages_for_channel(messages)
self.media.augment_media_urls(data)
return _http_json_response(data)
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
scope = self.workspaces.scope_for_session_key(decoded_key)
session_messages: list[dict[str, Any]] | None = None
if self.session_manager is not None:
session_data = self.session_manager.read_session_file(decoded_key)
raw_messages = session_data.get("messages") if isinstance(session_data, dict) else None
if isinstance(raw_messages, list):
session_messages = [m for m in raw_messages if isinstance(m, dict)]
query = _parse_query(request.path)
raw_limit = _query_first(query, "limit")
limit: int | None = None
if raw_limit is not None and raw_limit.strip():
try:
limit = int(raw_limit)
except ValueError:
return _http_error(400, "invalid limit")
direction = _query_first(query, "direction")
if direction is not None and direction not in {"latest"}:
return _http_error(400, "invalid direction")
before = _query_first(query, "before")
data = build_webui_thread_response(
decoded_key,
augment_user_media=self.media.augment_transcript_media,
augment_assistant_media=self.media.augment_transcript_media,
augment_assistant_text=lambda text: self.media.rewrite_local_markdown_images(
text,
workspace_path=scope.project_path,
),
session_messages=session_messages,
limit=limit,
direction=direction,
before=before,
)
if data is None:
return _http_error(404, "webui thread not found")
data["workspace_scope"] = scope.payload()
return _http_json_response(data)
def _handle_file_preview(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
path = _query_first(_parse_query(request.path), "path")
try:
payload = file_preview_payload(
path,
scope=self.workspaces.scope_for_session_key(decoded_key),
)
except WebUIFilePreviewError as e:
return _http_error(e.status, e.message)
return _http_json_response(payload)
def _handle_session_automations(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
pending_job_ids: set[str] = set()
if self.cron_pending_job_ids is not None:
pending_job_ids = self.cron_pending_job_ids(decoded_key)
return _http_json_response(
session_automations_payload(
self.cron_service,
decoded_key,
pending_job_ids=pending_job_ids,
)
)
def _handle_session_delete(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if self.session_manager is None:
return _http_error(503, "session manager unavailable")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not _is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
query = _parse_query(request.path)
delete_automations = (_query_first(query, "delete_automations") or "").lower()
automation_jobs = session_automation_jobs(self.cron_service, decoded_key)
if automation_jobs and delete_automations not in {"1", "true", "yes"}:
return _http_json_response(
{
"deleted": False,
"blocked_by_automations": True,
"automations": serialize_automation_jobs(automation_jobs),
}
)
if automation_jobs and self.cron_service is not None:
for job in automation_jobs:
self.cron_service.remove_job(job.id)
deleted = self.session_manager.delete_session(decoded_key)
delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)})
# -- Automation routes --------------------------------------------------
async def _dispatch_automation_routes(
self,
request: WsRequest,
got: str,
) -> Response | None:
if got == "/api/webui/automations":
return self._handle_webui_automations(request)
m = re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", got)
if m:
return await self._handle_webui_automation_action(request, m.group(1))
return None
def _pending_cron_job_ids_for_all(self) -> set[str]:
if self.cron_service is None or self.cron_pending_job_ids is None:
return set()
pending: set[str] = set()
for job in self.cron_service.list_jobs(include_disabled=True):
session_key = job.payload.session_key
if not session_key and job.payload.origin_channel and job.payload.origin_chat_id:
session_key = f"{job.payload.origin_channel}:{job.payload.origin_chat_id}"
if session_key:
pending.update(self.cron_pending_job_ids(session_key))
return pending
def _handle_webui_automations(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
all_automations_payload(
self.cron_service,
session_manager=self.session_manager,
pending_job_ids=self._pending_cron_job_ids_for_all(),
)
)
async def _handle_webui_automation_action(
self,
request: WsRequest,
action: str,
) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
if self.cron_service is None:
return _http_error(503, "cron service unavailable")
query = _parse_query(request.path)
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
if not job_id:
return _http_error(400, "missing automation id")
job = self.cron_service.get_job(job_id)
if job is None:
return _http_error(404, "automation not found")
if job.payload.kind == "system_event":
return _http_error(403, "system automation is protected")
if action == "enable":
if self.cron_service.enable_job(job_id, enabled=True) is None:
return _http_error(404, "automation not found")
elif action == "disable":
if self.cron_service.enable_job(job_id, enabled=False) is None:
return _http_error(404, "automation not found")
elif action == "delete":
result = self.cron_service.remove_job(job_id)
if result == "not_found":
return _http_error(404, "automation not found")
if result == "protected":
return _http_error(403, "system automation is protected")
elif action == "run":
if not job.enabled:
return _http_error(409, "automation is disabled")
task = asyncio.create_task(self.cron_service.run_job(job_id, force=False))
task.add_done_callback(self._log_automation_run_result)
elif action == "update":
values = _automation_values_from_request(request)
if values is None:
return _http_error(400, "invalid automation update payload")
parsed = _parse_automation_update(values)
if isinstance(parsed, str):
return _http_error(400, parsed)
try:
result = self.cron_service.update_job(job_id, **parsed)
except ValueError as exc:
return _http_error(400, str(exc))
if result == "not_found":
return _http_error(404, "automation not found")
if result == "protected":
return _http_error(403, "system automation is protected")
else:
return _http_error(404, "unknown automation action")
return self._handle_webui_automations(request)
@staticmethod
def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
try:
ran = task.result()
except Exception:
logger.exception("WebUI automation run-now task failed")
return
if not ran:
logger.warning("WebUI automation run-now task did not execute")
# -- Media routes -------------------------------------------------------
def _dispatch_media_routes(self, request: WsRequest, got: str) -> Response | None:
m = re.match(r"^/api/media/([A-Za-z0-9_-]+)/([A-Za-z0-9_-]+)$", got)
if m:
return self._handle_media_fetch(m.group(1), m.group(2), request)
return None
def _handle_media_fetch(
self, sig: str, payload: str, request: WsRequest | None = None
) -> Response:
return self.media.serve_signed_media(
sig,
payload,
request=request,
)
# -- Misc routes --------------------------------------------------------
async def _dispatch_misc_routes(
self, connection: Any, request: WsRequest, got: str
) -> Response | None:
if got == "/api/sessions":
return await self._handle_sessions_list(request)
if got == "/api/commands":
return self._handle_commands(request)
if got == "/api/workspaces":
return self._handle_workspaces(connection, request)
if got == "/api/webui/skills":
return self._handle_webui_skills(request)
m = re.match(r"^/api/webui/skills/([^/]+)$", got)
if m:
return self._handle_webui_skill_detail(request, m.group(1))
if got == "/api/webui/sidebar-state":
return self._handle_webui_sidebar_state(request)
if got == "/api/webui/sidebar-state/update":
return self._handle_webui_sidebar_state_update(request)
return None
def _handle_commands(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response({"commands": builtin_command_palette()})
def _handle_workspaces(self, connection: Any, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
self.workspaces.payload(
controls_available=self.workspace_controls_available(connection)
)
)
def _handle_webui_skills(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(
webui_skills_payload(
self.skills_workspace_path,
disabled_skills=self.disabled_skills,
)
)
def _handle_webui_skill_detail(self, request: WsRequest, raw_name: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
from urllib.parse import unquote
name = unquote(raw_name)
if not name or "/" in name or "\\" in name:
return _http_error(400, "invalid skill name")
payload = webui_skill_detail_payload(
self.skills_workspace_path,
name,
disabled_skills=self.disabled_skills,
)
if payload is None:
return _http_error(404, "skill not found")
return _http_json_response(payload)
def _handle_webui_sidebar_state(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(read_webui_sidebar_state())
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
raw_state = _query_first(query, "state")
if raw_state is None:
return _http_error(400, "missing state")
try:
decoded = json.loads(raw_state)
except json.JSONDecodeError:
return _http_error(400, "state must be JSON")
if not isinstance(decoded, dict):
return _http_error(400, "state must be an object")
try:
state = write_webui_sidebar_state(decoded)
except ValueError as e:
return _http_error(400, str(e))
except OSError:
self._log.exception("failed to write webui sidebar state")
return _http_error(500, "failed to write sidebar state")
return _http_json_response(state)
# -- Static file serving ------------------------------------------------
def _serve_static(self, request_path: str) -> Response | None:
assert self.static_dist_path is not None
rel = request_path.lstrip("/")
if not rel:
rel = "index.html"
if ".." in rel.split("/") or rel.startswith("/"):
return _http_error(403, "Forbidden")
candidate = (self.static_dist_path / rel).resolve()
try:
candidate.relative_to(self.static_dist_path)
except ValueError:
return _http_error(403, "Forbidden")
if not candidate.is_file():
index = self.static_dist_path / "index.html"
if index.is_file():
candidate = index
else:
return None
try:
body = candidate.read_bytes()
except OSError as e:
self._log.warning("static: failed to read {}: {}", candidate, e)
return _http_error(500, "Internal Server Error")
ctype, _ = mimetypes.guess_type(candidate.name)
if ctype is None:
ctype = "application/octet-stream"
if ctype.startswith("text/") or ctype in {"application/javascript", "application/json"}:
ctype = f"{ctype}; charset=utf-8"
if candidate.name == "index.html":
cache = "no-cache"
else:
cache = "public, max-age=31536000, immutable"
return _http_response(
body,
status=200,
content_type=ctype,
extra_headers=[("Cache-Control", cache)],
)
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
if not raw:
return {}
try:
values = json.loads(raw)
except Exception:
return None
return values if isinstance(values, dict) else None
def _parse_automation_update(values: dict[str, Any]) -> dict[str, Any] | str:
update: dict[str, Any] = {}
if "name" in values:
raw_name = values.get("name")
if not isinstance(raw_name, str):
return "name must be a string"
name = raw_name.strip()
if not name:
return "name cannot be empty"
update["name"] = name
if "message" in values:
raw_message = values.get("message")
if not isinstance(raw_message, str):
return "message must be a string"
message = raw_message.strip()
if not message:
return "message cannot be empty"
update["message"] = message
if "schedule" in values:
raw_schedule = values.get("schedule")
if not isinstance(raw_schedule, dict):
return "schedule must be an object"
parsed_schedule = _parse_automation_schedule(raw_schedule)
if isinstance(parsed_schedule, str):
return parsed_schedule
schedule_error = _validate_automation_schedule(parsed_schedule)
if schedule_error:
return schedule_error
update["schedule"] = parsed_schedule
update["delete_after_run"] = parsed_schedule.kind == "at"
return update
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
raw_kind = values.get("kind")
if not isinstance(raw_kind, str):
return "schedule kind must be a string"
kind = raw_kind.strip()
if kind == "every":
every_ms = _positive_int(values.get("every_ms"))
if every_ms is None:
return "every schedule requires positive every_ms"
return CronSchedule(kind="every", every_ms=every_ms)
if kind == "cron":
raw_expr = values.get("expr")
if not isinstance(raw_expr, str):
return "cron schedule requires expr"
expr = raw_expr.strip()
if not expr:
return "cron schedule requires expr"
raw_tz = values.get("tz")
if raw_tz is not None and not isinstance(raw_tz, str):
return "cron schedule timezone must be a string"
tz = raw_tz.strip() if isinstance(raw_tz, str) else ""
return CronSchedule(kind="cron", expr=expr, tz=tz or None)
if kind == "at":
at_ms = _positive_int(values.get("at_ms"))
if at_ms is None:
return "one-time schedule requires positive at_ms"
return CronSchedule(kind="at", at_ms=at_ms)
return "unknown schedule kind"
def _validate_automation_schedule(schedule: CronSchedule) -> str | None:
if schedule.kind == "at":
if not schedule.at_ms or schedule.at_ms <= int(time.time() * 1000):
return "one-time schedule must be in the future"
return None
if schedule.kind != "cron":
return None
try:
from datetime import datetime
from zoneinfo import ZoneInfo
from croniter import croniter
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
base = datetime.now(tz=tz)
croniter(schedule.expr, base).get_next(datetime)
except Exception:
return "cron schedule is invalid"
return None
def _positive_int(value: Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, int):
return None
return value if value > 0 else None
def _is_websocket_channel_session_key(key: str) -> bool:
return key.startswith("websocket:")