From 2c9862c3156c399a25c457bb246b65eeb277894b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 2 Jul 2026 17:54:31 +0200 Subject: [PATCH] feat(automations): add chat watch service --- .../app/automations/services/chat_watch.py | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 surfsense_backend/app/automations/services/chat_watch.py diff --git a/surfsense_backend/app/automations/services/chat_watch.py b/surfsense_backend/app/automations/services/chat_watch.py new file mode 100644 index 000000000..5f72ca98e --- /dev/null +++ b/surfsense_backend/app/automations/services/chat_watch.py @@ -0,0 +1,152 @@ +"""A chat watch is an automation bound to a chat: a ``schedule`` trigger firing +a single ``chat_message`` step that re-posts the question into the same thread. + +Starting a watch creates that automation; stopping deletes it. Whether a chat +is watched is derived from the plan (``plan_targets_thread``), not a stored flag. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from app.automations.persistence.enums.trigger_type import TriggerType +from app.automations.persistence.models.automation import Automation +from app.automations.persistence.models.run import AutomationRun +from app.automations.persistence.models.trigger import AutomationTrigger +from app.automations.schemas.api import AutomationCreate, TriggerCreate +from app.automations.schemas.definition import AutomationDefinition, PlanStep +from app.automations.services.automation import AutomationService + +WATCH_ACTION_TYPE = "chat_message" +_WATCH_STEP_ID = "watch" +_NAME_MAX = 200 +# Watches per chat are few; one generous page finds them all. +_WATCH_SCAN_LIMIT = 500 + + +def _derive_name(message: str) -> str: + """A short, human name for the watch from the question it re-asks.""" + condensed = " ".join(message.split()) + label = condensed[:80].rstrip() + return f"Watch: {label}" if label else "Watch" + + +def _build_watch_payload( + *, + workspace_id: int, + thread_id: int, + message: str, + cron: str, + timezone: str, + name: str | None, + description: str | None, +) -> AutomationCreate: + watch_name = (name or _derive_name(message))[:_NAME_MAX] + return AutomationCreate( + workspace_id=workspace_id, + name=watch_name, + description=description, + definition=AutomationDefinition( + name=watch_name, + plan=[ + PlanStep( + step_id=_WATCH_STEP_ID, + action=WATCH_ACTION_TYPE, + params={"thread_id": thread_id, "message": message}, + ) + ], + ), + triggers=[ + TriggerCreate( + type=TriggerType.SCHEDULE, + params={"cron": cron, "timezone": timezone}, + enabled=True, + ) + ], + ) + + +async def create_watch( + service: AutomationService, + *, + workspace_id: int, + thread_id: int, + message: str, + cron: str, + timezone: str, + name: str | None = None, + description: str | None = None, +) -> Automation: + """Bind a schedule + chat_message watch automation to ``thread_id``.""" + payload = _build_watch_payload( + workspace_id=workspace_id, + thread_id=thread_id, + message=message, + cron=cron, + timezone=timezone, + name=name, + description=description, + ) + return await service.create(payload) + + +async def stop_watch(service: AutomationService, *, automation_id: int) -> None: + """Stop a watch by deleting its automation; the chat reverts to normal.""" + await service.delete(automation_id) + + +def plan_targets_thread(definition: dict[str, Any] | None, thread_id: int) -> bool: + """Whether an automation's plan re-posts into ``thread_id`` (i.e. is a watch). + + Pure predicate over the persisted ``definition`` JSON so callers can filter + a workspace's automations without a DB round-trip per row. + """ + for step in (definition or {}).get("plan", []): + if step.get("action") != WATCH_ACTION_TYPE: + continue + if (step.get("params") or {}).get("thread_id") == thread_id: + return True + return False + + +async def find_watches_for_thread( + service: AutomationService, + *, + workspace_id: int, + thread_id: int, +) -> list[Automation]: + """Return the workspace automations that watch ``thread_id`` (i.e. its watches).""" + automations, _total = await service.list( + workspace_id=workspace_id, limit=_WATCH_SCAN_LIMIT, offset=0 + ) + return [a for a in automations if plan_targets_thread(a.definition, thread_id)] + + +def schedule_trigger(automation: Automation) -> AutomationTrigger | None: + """The automation's ``schedule`` trigger row, or ``None``.""" + for trigger in automation.triggers: + if trigger.type == TriggerType.SCHEDULE: + return trigger + return None + + +async def run_watch_now( + service: AutomationService, + *, + automation_id: int, +) -> AutomationRun: + """Enqueue an immediate run of a watch (a manual refresh).""" + # Lazy: launch_run pulls in the automation task graph, which imports + # multi_agent_chat; this module is imported from there, so a top-level + # import would cycle. + from app.automations.dispatch import launch as launch_mod + + automation = await service.get(automation_id) + trigger = schedule_trigger(automation) + if trigger is None: + raise HTTPException( + status_code=422, detail="watch has no schedule trigger to run" + ) + return await launch_mod.launch_run(session=service.session, trigger=trigger)