"""Automation payloads for the embedded WebUI.""" from __future__ import annotations from collections.abc import Collection from typing import Any, Protocol, cast from nanobot.cron.types import CronJob from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.manager import _message_preview_text # pyright: ignore[reportPrivateUsage] from nanobot.triggers.local_types import LocalTrigger AutomationJob = CronJob | LocalTrigger class _CronServiceLike(Protocol): def list_jobs(self, include_disabled: bool = False) -> list[CronJob]: ... def list_bound_cron_jobs_for_session( self, session_key: str, *, include_disabled: bool = True, ) -> list[CronJob]: ... class _LocalTriggerStoreLike(Protocol): def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]: ... def list_for_session( self, session_key: str, *, include_disabled: bool = True, ) -> list[LocalTrigger]: ... class _SessionManagerLike(Protocol): def read_session_file(self, key: str) -> dict[str, Any] | None: ... def session_automation_jobs( cron_service: _CronServiceLike | None, session_key: str, *, local_trigger_store: _LocalTriggerStoreLike | None = None, ) -> list[AutomationJob]: """Return user automations attached to the WebUI session.""" jobs: list[AutomationJob] = [] if cron_service is not None: jobs.extend( cron_service.list_bound_cron_jobs_for_session( session_key, include_disabled=True, ) ) if local_trigger_store is not None: jobs.extend( local_trigger_store.list_for_session( session_key, include_disabled=True, ) ) return jobs def session_automations_payload( cron_service: _CronServiceLike | None, session_key: str, *, local_trigger_store: _LocalTriggerStoreLike | None = None, pending_job_ids: Collection[str] | None = None, ) -> dict[str, Any]: """Return user-created automation jobs attached to a WebUI session.""" return { "jobs": serialize_automation_jobs( session_automation_jobs( cron_service, session_key, local_trigger_store=local_trigger_store, ), pending_job_ids=pending_job_ids, ) } def all_automations_payload( cron_service: _CronServiceLike | None, *, local_trigger_store: _LocalTriggerStoreLike | None = None, session_manager: _SessionManagerLike | None = None, pending_job_ids: Collection[str] | None = None, ) -> dict[str, Any]: """Return all cron jobs visible to the WebUI automation manager.""" jobs: list[AutomationJob] = [] if cron_service is not None: jobs.extend(cron_service.list_jobs(include_disabled=True)) if local_trigger_store is not None: jobs.extend(local_trigger_store.list_triggers(include_disabled=True)) return { "jobs": serialize_automation_jobs( jobs, pending_job_ids=pending_job_ids, include_details=True, session_manager=session_manager, ) } def serialize_automation_jobs( jobs: list[AutomationJob], *, pending_job_ids: Collection[str] | None = None, include_details: bool = False, session_manager: _SessionManagerLike | None = None, ) -> list[dict[str, Any]]: return [ _serialize_job( job, pending=job.id in (pending_job_ids or ()), include_details=include_details, session_manager=session_manager, ) for job in jobs ] def _serialize_job( job: AutomationJob, *, pending: bool = False, include_details: bool = False, session_manager: _SessionManagerLike | None = None, ) -> dict[str, Any]: if isinstance(job, LocalTrigger): return _serialize_trigger( job, pending=pending, include_details=include_details, session_manager=session_manager, ) payload: dict[str, Any] = { "id": job.id, "name": job.name, "enabled": job.enabled, "schedule": { "kind": job.schedule.kind, "at_ms": job.schedule.at_ms, "every_ms": job.schedule.every_ms, "expr": job.schedule.expr, "tz": job.schedule.tz, }, "payload": { "message": job.payload.message, }, "state": { "next_run_at_ms": job.state.next_run_at_ms, "last_status": job.state.last_status, "pending": pending, }, } if not include_details: return payload payload["protected"] = job.payload.kind == "system_event" 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}) payload["state"].update( { "last_run_at_ms": job.state.last_run_at_ms, "last_error": job.state.last_error, "run_history": [ { "run_at_ms": record.run_at_ms, "status": record.status, "duration_ms": record.duration_ms, "error": record.error, } for record in job.state.run_history[-5:] ], } ) payload["origin"] = _origin_payload(job, session_manager) return payload def _serialize_trigger( trigger: LocalTrigger, *, pending: bool = False, include_details: bool = False, session_manager: _SessionManagerLike | None = None, ) -> dict[str, Any]: command = f'nanobot trigger {trigger.id} "message"' payload: dict[str, Any] = { "id": trigger.id, "name": trigger.name, "enabled": trigger.enabled, "kind": "local_trigger", "schedule": { "kind": "local", "at_ms": None, "every_ms": None, "expr": None, "tz": None, }, "payload": { "kind": "local_trigger", "message": trigger.last_message or command, "command": command, }, "state": { "next_run_at_ms": None, "last_status": trigger.last_status, "pending": pending, }, } if not include_details: return payload payload["protected"] = False payload["delete_after_run"] = False payload["created_at_ms"] = trigger.created_at_ms payload["updated_at_ms"] = trigger.updated_at_ms payload["state"].update( { "last_run_at_ms": trigger.last_run_at_ms, "last_error": trigger.last_error, "run_history": [ { "run_at_ms": record.run_at_ms, "status": record.status, "duration_ms": 0, "error": record.error, } for record in trigger.run_history[-5:] ], } ) payload["origin"] = _trigger_origin_payload(trigger, session_manager) payload["trigger"] = { "id": trigger.id, "command": command, } return payload def _origin_payload( job: CronJob, session_manager: _SessionManagerLike | None, ) -> dict[str, Any] | None: channel = job.payload.origin_channel chat_id = job.payload.origin_chat_id if not channel or not chat_id: return None title = "" preview = "" if channel != "websocket": return { "channel": channel, "title": title, "preview": preview, } session_key = f"{channel}:{chat_id}" return _websocket_origin_payload( session_key=session_key, channel=channel, chat_id=chat_id, session_manager=session_manager, ) def _trigger_origin_payload( trigger: LocalTrigger, session_manager: _SessionManagerLike | None, ) -> dict[str, Any] | None: channel = trigger.channel chat_id = trigger.chat_id if not channel or not chat_id: return None if channel != "websocket": return { "channel": channel, "title": "", "preview": "", } return _websocket_origin_payload( session_key=trigger.session_key or f"{channel}:{chat_id}", channel=channel, chat_id=chat_id, session_manager=session_manager, ) def _websocket_origin_payload( *, session_key: str, channel: str, chat_id: str, session_manager: _SessionManagerLike | None, ) -> dict[str, Any]: title = "" preview = "" if session_manager is not None: data = session_manager.read_session_file(session_key) if isinstance(data, dict): title = str(data.get("title") or "") preview = _session_preview(data.get("messages")) return { "session_key": session_key, "channel": channel, "chat_id": chat_id, "title": title, "preview": preview, } def _session_preview(messages: Any) -> str: if not isinstance(messages, list): return "" fallback_preview = "" for message_value in cast(list[object], messages): if not isinstance(message_value, dict): continue message = cast(dict[str, Any], message_value) if is_hidden_history_message(message): continue text = _message_preview_text(message) if not text: continue if message.get("role") == "user": return text if not fallback_preview and message.get("role") == "assistant": fallback_preview = text return fallback_preview