Files

105 lines
3.8 KiB
Python

"""Routes for the event engine. POST /api/events/ingest is the universal push
entry: any script, webhook forwarder, macOS Shortcut, or MCP can feed a
workflow's custom trigger. Ingested events ride the SAME dispatcher path as
polled ones, so coalescing, the predicate, and the rate cap all still apply.
The engine's lifecycle itself rides workflows_lifespan; this SubApp is
routes-only. Auth: the per-install bearer token gates this like every other
localhost API route."""
from contextlib import asynccontextmanager
from typing import Dict
from uuid import uuid4
from fastapi import HTTPException
from pydantic import BaseModel, Field
from backend.apps.events import dispatcher, stores
from backend.apps.events.models import Event
from backend.config.Apps import SubApp
@asynccontextmanager
async def events_lifespan():
yield
events = SubApp("events", events_lifespan)
MAX_SEEN_KEYS = 300
class IngestBody(BaseModel):
workflow_id: str
trigger_id: str
summary: str
event_type: str = "custom"
# Same key twice = delivered once; lets webhook retries stay idempotent.
dedup_key: str = ""
payload: Dict = Field(default_factory=dict)
class IngestPushBody(BaseModel):
summary: str
event_type: str = "custom"
dedup_key: str = ""
payload: Dict = Field(default_factory=dict)
async def p_do_ingest(workflow_id: str, trigger, body: IngestPushBody) -> Dict:
if trigger.source.kind != "custom":
raise HTTPException(status_code=409, detail="Trigger is not a custom (ingest) source")
if not trigger.enabled:
raise HTTPException(status_code=409, detail="Trigger is disabled")
summary = body.summary.strip()[:300]
if not summary:
raise HTTPException(status_code=400, detail="summary is required")
dedup_key = body.dedup_key.strip() or uuid4().hex
cursor = stores.load_cursor(trigger.id)
seen = [str(k) for k in (cursor.get("seen") or [])]
if dedup_key in seen:
return {"ok": True, "queued": 0, "deduped": True}
seen.append(dedup_key)
stores.save_cursor(trigger.id, {"seen": seen[-MAX_SEEN_KEYS:]})
await dispatcher.ingest(workflow_id, trigger, [Event(
source="custom",
event_type=(body.event_type.strip() or "custom")[:60],
summary=summary,
dedup_key=dedup_key,
payload=body.payload,
)])
return {"ok": True, "queued": 1, "deduped": False}
@events.router.post("/ingest")
async def ingest_event(body: IngestBody):
from backend.apps.workflows import storage
wf = storage.get_workflow(body.workflow_id)
if wf is None or wf.deleted_at is not None:
raise HTTPException(status_code=404, detail="Workflow not found")
trigger = next((t for t in wf.event_triggers if t.id == body.trigger_id), None)
if trigger is None:
raise HTTPException(status_code=404, detail="Trigger not found")
return await p_do_ingest(wf.id, trigger, IngestPushBody(
summary=body.summary, event_type=body.event_type, dedup_key=body.dedup_key, payload=body.payload,
))
@events.router.post("/ingest/{secret}")
async def ingest_event_by_secret(secret: str, body: IngestPushBody):
"""Paste-one-URL push: the per-trigger secret in the path IS the credential
(auth-middleware exempt; same entropy class as the install token, localhost-bound,
revoked by deleting the trigger)."""
import hmac
from backend.apps.workflows import storage
if len(secret.strip()) < 16:
raise HTTPException(status_code=404, detail="Unknown ingest URL")
for wf in storage.list_workflows():
for trigger in wf.event_triggers:
trigger_secret = str(getattr(trigger.source, "secret", "") or "")
if trigger_secret and hmac.compare_digest(trigger_secret, secret):
return await p_do_ingest(wf.id, trigger, body)
raise HTTPException(status_code=404, detail="Unknown ingest URL")