main.py (5146 lines, 106 routes) becomes an assembly only: one package per domain — core, files, settings, dashboards, conversations, diff, notifications, forms, runs, accounts, workers, models, cron, agents, projects, services, goals, memories, plans, templates — each exposing an APIRouter; the flat domain modules move into their package behind a barrel that keeps the old `import conversations` / `import projects` spellings. The shared singletons (store, meta_store, hub, indexer, …) are built once by core.state.build_state() and attached to app.state.ai; routes take them as the `deps: State` dependency and helpers as an explicit `deps: AppState`. conversations/pricing.py carries the per-model rates out of the parser. Verified: route table and OpenAPI byte-identical; 90 read endpoints golden-diffed against the monolith on a copy of the live data (identical); write routes smoke-tested; 66 backend tests pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
"""Settings pages: PWA ui-state, native Claude Code hooks, webhooks and the
|
|
mail-trigger allowlist."""
|
|
|
|
import json
|
|
import uuid as uuidlib
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
import schemas
|
|
from core.config import WORKSPACE
|
|
from core.http import _r
|
|
from core.state import State
|
|
from notifications import store as notify_mod
|
|
from settings.ui_state import valid_key
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── UI state (Settings config + notification bookkeeping) ────────────────────
|
|
# The PWA persists its small Zustand `persist` stores here instead of the
|
|
# browser's localStorage, so config and the notification feed's seen/new state
|
|
# follow the user across browsers/devices. The value is the store's opaque
|
|
# serialized JSON blob; the backend just holds the string (see ui_state.py).
|
|
|
|
class UiStateBody(BaseModel):
|
|
value: str
|
|
|
|
|
|
@router.get("/api/ui-state/{key}", responses=_r(schemas.UiStateValue))
|
|
def get_ui_state(deps: State, key: str):
|
|
if not valid_key(key):
|
|
raise HTTPException(400, "bad key")
|
|
return {"value": deps.ui_state.get(key)}
|
|
|
|
|
|
@router.put("/api/ui-state/{key}", responses=_r(schemas.OkResponse))
|
|
def put_ui_state(deps: State, key: str, body: UiStateBody):
|
|
if not valid_key(key):
|
|
raise HTTPException(400, "bad key")
|
|
deps.ui_state.set(key, body.value)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.delete("/api/ui-state/{key}", responses=_r(schemas.OkResponse))
|
|
def delete_ui_state(deps: State, key: str):
|
|
if not valid_key(key):
|
|
raise HTTPException(400, "bad key")
|
|
deps.ui_state.delete(key)
|
|
return {"ok": True}
|
|
|
|
|
|
# Claude Code's own hooks, read out of the workspace's .claude/settings.json.
|
|
# Read-only on purpose: writing that file from the container is host-level code
|
|
# execution (see the trusted-caller note at the top of this module), so the
|
|
# page shows them and points at the repo for edits.
|
|
CLAUDE_SETTINGS_REL = ".claude/settings.json"
|
|
|
|
|
|
@router.get("/api/claude-hooks", responses=_r(schemas.ClaudeHooksResponse))
|
|
def claude_hooks():
|
|
"""The native Claude Code hooks registered in the workspace repo, flattened
|
|
to one entry per (event, matcher, command). A missing or unparseable
|
|
settings file is an empty list, not an error (standalone has no repo)."""
|
|
ap = WORKSPACE / CLAUDE_SETTINGS_REL
|
|
try:
|
|
data = json.loads(ap.read_text(encoding="utf-8")) or {}
|
|
except (OSError, ValueError):
|
|
return {"path": CLAUDE_SETTINGS_REL, "exists": ap.exists(),
|
|
"entries": []}
|
|
entries = []
|
|
blocks = data.get("hooks")
|
|
for event, matchers in (blocks or {}).items():
|
|
for m in (matchers if isinstance(matchers, list) else []):
|
|
if not isinstance(m, dict):
|
|
continue
|
|
for h in (m.get("hooks") or []):
|
|
if not isinstance(h, dict):
|
|
continue
|
|
entries.append({"event": str(event),
|
|
"matcher": m.get("matcher") or None,
|
|
"type": h.get("type") or "command",
|
|
"command": h.get("command") or ""})
|
|
return {"path": CLAUDE_SETTINGS_REL, "exists": True, "entries": entries}
|
|
|
|
|
|
# ── first-class notifications: webhooks + notify/ask (see notify.py) ─────────
|
|
# This backend is the notification source of truth: the notify-done skill
|
|
# POSTs pushes and asks here; they are recorded in /data/notifications.json and
|
|
# forwarded to the configured webhooks — normally the Home Assistant webhook
|
|
# registered by the `ai_agent` custom integration (integrations/home-assistant/),
|
|
# which renders the phone notification and POSTs ask answers back.
|
|
class WebhooksBody(BaseModel):
|
|
webhooks: list[dict]
|
|
|
|
|
|
class MailTriggerSendersBody(BaseModel):
|
|
emails: list[str]
|
|
# Kept in the list but not allowed to trigger (the Settings toggle).
|
|
disabled: list[str] | None = None
|
|
|
|
|
|
@router.get("/api/webhooks", responses=_r(schemas.WebhooksResponse))
|
|
def webhooks_get(deps: State):
|
|
return {"webhooks": deps.notify_store.webhooks()}
|
|
|
|
|
|
@router.put("/api/webhooks", responses=_r(schemas.WebhooksResponse))
|
|
def webhooks_put(deps: State, body: WebhooksBody):
|
|
try:
|
|
saved = deps.notify_store.set_webhooks(body.webhooks)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
deps.hub.publish({"type": "webhooks"})
|
|
return {"webhooks": saved}
|
|
|
|
|
|
@router.get("/api/mail-trigger-senders", responses=_r(schemas.MailTriggerSendersResponse))
|
|
def mail_trigger_senders_get(deps: State):
|
|
return {"emails": deps.mail_trigger_store.emails(),
|
|
"disabled": deps.mail_trigger_store.disabled()}
|
|
|
|
|
|
@router.put("/api/mail-trigger-senders", responses=_r(schemas.MailTriggerSendersResponse))
|
|
def mail_trigger_senders_put(deps: State, body: MailTriggerSendersBody):
|
|
try:
|
|
saved = deps.mail_trigger_store.set_emails(body.emails, body.disabled)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
deps.hub.publish({"type": "mail-trigger-senders"})
|
|
return saved
|
|
|
|
|
|
@router.post("/api/webhooks/{webhook_id}/test", responses=_r(schemas.NotifyResult))
|
|
def webhook_test(deps: State, webhook_id: str):
|
|
"""Send a test push through one webhook (regardless of enabled/events)."""
|
|
target = next((w for w in deps.notify_store.webhooks()
|
|
if w.get("id") == webhook_id), None)
|
|
if not target:
|
|
raise HTTPException(404, "unknown webhook")
|
|
nid = "ntf_" + uuidlib.uuid4().hex[:12]
|
|
delivered = notify_mod.forward([target], {
|
|
"event": "notify", "id": nid, "title": "ai-agent test",
|
|
"message": f"Webhook '{target['name']}' is wired up.",
|
|
"type": "success", "url": "https://ai-agent.lab.gabvdl.xyz"})
|
|
return {"ok": all(d["ok"] for d in delivered), "id": nid,
|
|
"delivered": delivered}
|