Files
ai-agent/backend/settings/routes.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
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>
2026-10-06 23:55:47 +02:00

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}