diff --git a/nanobot/webui/session_automations.py b/nanobot/webui/session_automations.py index a63cf4da..0780d781 100644 --- a/nanobot/webui/session_automations.py +++ b/nanobot/webui/session_automations.py @@ -121,14 +121,17 @@ def _serialize_job( payload["delete_after_run"] = job.delete_after_run payload["created_at_ms"] = job.created_at_ms payload["updated_at_ms"] = job.updated_at_ms - payload["payload"].update( - { - "kind": job.payload.kind, - "session_key": job.payload.session_key, - "origin_channel": job.payload.origin_channel, - "origin_chat_id": job.payload.origin_chat_id, - } - ) + payload["payload"].update({"kind": job.payload.kind}) + if _expose_origin_identifiers(job): + payload["payload"].update( + { + "session_key": job.payload.session_key, + "origin_channel": job.payload.origin_channel, + "origin_chat_id": job.payload.origin_chat_id, + } + ) + elif job.payload.origin_channel: + payload["payload"]["origin_channel"] = job.payload.origin_channel payload["state"].update( { "last_run_at_ms": job.state.last_run_at_ms, @@ -156,11 +159,17 @@ def _origin_payload( chat_id = job.payload.origin_chat_id if not channel or not chat_id: return None - session_key = f"{channel}:{chat_id}" - title = "" preview = "" - if channel == "websocket" and session_manager is not None: + if channel != "websocket": + return { + "channel": channel, + "title": title, + "preview": preview, + } + + session_key = f"{channel}:{chat_id}" + if session_manager is not None: data = session_manager.read_session_file(session_key) if isinstance(data, dict): title = str(data.get("title") or "") @@ -175,6 +184,11 @@ def _origin_payload( } +def _expose_origin_identifiers(job: CronJob) -> bool: + channel = job.payload.origin_channel + return not channel or channel == "websocket" + + def _session_preview(messages: Any) -> str: if not isinstance(messages, list): return "" diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index e8b4445e..99b1f92e 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -789,12 +789,18 @@ def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None def _parse_automation_update(values: dict[str, Any]) -> dict[str, Any] | str: update: dict[str, Any] = {} if "name" in values: - name = str(values.get("name") or "").strip() + 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: - message = str(values.get("message") or "").strip() + 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 @@ -805,24 +811,36 @@ def _parse_automation_update(values: dict[str, Any]) -> dict[str, Any] | str: 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: - kind = str(values.get("kind") or "").strip() + 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": - expr = str(values.get("expr") or "").strip() + 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" - tz = str(values.get("tz") or "").strip() or None - return CronSchedule(kind="cron", expr=expr, tz=tz) + 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: @@ -831,14 +849,32 @@ def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str: return "unknown schedule kind" -def _positive_int(value: Any) -> int | None: - if isinstance(value, bool): +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: - parsed = int(value) - except (TypeError, ValueError): + 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 parsed if parsed > 0 else None + return value if value > 0 else None def _is_websocket_channel_session_key(key: str) -> bool: diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index 06912f69..d08681d4 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -889,6 +889,7 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( headers=auth, ) assert resp.status_code == 200 + assert "wx-chat" not in resp.text body = resp.json() by_id = {job["id"]: job for job in body["jobs"]} assert by_id[user_job.id]["protected"] is False @@ -898,7 +899,12 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[user_job.id]["origin"]["preview"] == "hi" assert by_id[legacy_job.id]["payload"]["session_key"] == "unified:default" assert by_id[legacy_job.id]["origin"] is None - assert by_id[external_job.id]["origin"]["session_key"] == "weixin:wx-chat" + assert by_id[external_job.id]["payload"]["origin_channel"] == "weixin" + assert "session_key" not in by_id[external_job.id]["payload"] + assert "origin_chat_id" not in by_id[external_job.id]["payload"] + assert by_id[external_job.id]["origin"]["channel"] == "weixin" + assert "session_key" not in by_id[external_job.id]["origin"] + assert "chat_id" not in by_id[external_job.id]["origin"] assert by_id[external_job.id]["origin"]["preview"] == "" assert by_id["heartbeat"]["protected"] is True @@ -927,6 +933,28 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions( assert by_id[user_job.id]["schedule"]["expr"] == "0 9 * * *" assert by_id[user_job.id]["schedule"]["tz"] == "UTC" + malformed_update = await _http_get( + f"{base_url}/api/webui/automations/update?id={user_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps({"message": ["bad"]}), + }, + ) + assert malformed_update.status_code == 400 + assert cron.get_job(user_job.id).payload.message == "Ask the daily quiz" + + invalid_cron_update = await _http_get( + f"{base_url}/api/webui/automations/update?id={user_job.id}", + headers={ + **auth, + "X-Nanobot-Automation-Values": json.dumps( + {"schedule": {"kind": "cron", "expr": "not a cron", "tz": "UTC"}} + ), + }, + ) + assert invalid_cron_update.status_code == 400 + assert cron.get_job(user_job.id).schedule.expr == "0 9 * * *" + protected_update = await _http_get( f"{base_url}/api/webui/automations/update?id=heartbeat", headers={ diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 2cf03b23..5dc00e14 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -3583,7 +3583,7 @@ function AutomationRow({ t(key, { defaultValue: fallback, ...(values ?? {}) }); const status = automationStatus(job, tx); const origin = automationOriginLabel(job, tx); - const originHref = job.origin?.channel === "websocket" + const originHref = job.origin?.channel === "websocket" && job.origin.session_key ? `#/chat/${encodeURIComponent(job.origin.session_key)}` : null; const history = job.state.run_history ?? []; @@ -4257,7 +4257,7 @@ function automationOriginLabel( } if (!origin) return tx("settings.automations.origin.unknown", "No linked chat"); if (origin.channel !== "websocket") return automationChannelLabel(origin.channel, tx); - return origin.title || origin.preview || origin.session_key; + return origin.title || origin.preview || origin.session_key || automationChannelLabel(origin.channel, tx); } function automationChannelLabel( diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 99d9c858..436aa8d6 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -132,9 +132,9 @@ export interface SessionAutomationJob { }>; }; origin?: { - session_key: string; + session_key?: string; channel: string; - chat_id: string; + chat_id?: string; title?: string; preview?: string; } | null; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 7e858b62..8303fe20 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -395,9 +395,7 @@ describe("App layout", () => { payload: { message: "Send a quiz", kind: "agent_turn", - session_key: "weixin:wx-chat", origin_channel: "weixin", - origin_chat_id: "wx-chat", }, state: { next_run_at_ms: Date.UTC(2026, 3, 17, 11, 30, 0), @@ -406,11 +404,9 @@ describe("App layout", () => { run_history: [], }, origin: { - session_key: "weixin:wx-chat", channel: "weixin", - chat_id: "wx-chat", - title: "Scheduled cron job triggered", - preview: "memory with dream state", + title: "", + preview: "", }, }, {