mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
152 lines
4.9 KiB
Python
152 lines
4.9 KiB
Python
"""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)
|