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>
80 lines
3.3 KiB
Python
80 lines
3.3 KiB
Python
"""Creating a form (the ask-form skill's entry point) and its inlined assets."""
|
|
|
|
import base64
|
|
import binascii
|
|
import pathlib
|
|
import uuid as uuidlib
|
|
|
|
from fastapi import HTTPException
|
|
|
|
import forms as forms_mod
|
|
from core.config import MAX_UPLOAD_BYTES, UPLOADS_DIR
|
|
from core.state import AppState
|
|
from files.assets import _safe_upload_name
|
|
from notifications.service import _client_id, _conversation_url, _create_notify
|
|
|
|
|
|
def _create_form(deps: AppState, d: dict) -> dict:
|
|
"""Create a form (FormCreateBody dump in) — the route and the outbox
|
|
mirror both come through here. With ``notify`` the hub also sends the
|
|
push that deep-links to it; the result carries ``focusUrl``."""
|
|
if not (d.get("title") or "").strip():
|
|
raise HTTPException(400, "need a title")
|
|
raw_fields = d.get("fields") or []
|
|
description = (d.get("description") or "").strip()
|
|
if d.get("assets"):
|
|
urls = _store_form_assets(d["assets"])
|
|
raw_fields = forms_mod.apply_assets(raw_fields, urls)
|
|
description = forms_mod.apply_assets(description, urls)
|
|
try:
|
|
fields = forms_mod.valid_fields(raw_fields)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
fid = _client_id(d.get("id"), "frm")
|
|
sid = (d.get("sessionId") or "").strip()
|
|
url = (d.get("url") or "").strip() or _conversation_url(deps, sid)
|
|
form = deps.form_store.create(
|
|
d["title"].strip(), fields, description=description,
|
|
type_=d.get("type") or "", url=url, session_id=sid, fid=fid)
|
|
deps.hub.publish({"type": "form", "id": form["id"],
|
|
"sessionId": form["sessionId"]})
|
|
form["focusUrl"] = f"{url}?form={form['id']}"
|
|
if d.get("notify"):
|
|
res = _create_notify(deps, {
|
|
"title": "📋 " + form["title"],
|
|
"message": form["description"] or "Tap to fill in the form",
|
|
"type": form["type"] or "ai", "url": form["focusUrl"],
|
|
"sessionId": sid})
|
|
form["delivered"] = res.get("delivered") or []
|
|
return form
|
|
|
|
|
|
def _store_form_assets(assets: list) -> dict[str, str]:
|
|
"""Decode a form's inlined assets into one uploads batch; name → serve URL."""
|
|
if not isinstance(assets, list) or len(assets) > 20:
|
|
raise HTTPException(400, "assets: at most 20 files")
|
|
batch = uuidlib.uuid4().hex[:12]
|
|
dest = UPLOADS_DIR / batch
|
|
urls: dict[str, str] = {}
|
|
for a in assets:
|
|
if not isinstance(a, dict):
|
|
raise HTTPException(400, "assets: each entry must be an object")
|
|
name = _safe_upload_name(str(a.get("name") or "asset"))
|
|
try:
|
|
data = base64.b64decode(str(a.get("data") or ""), validate=True)
|
|
except (ValueError, binascii.Error):
|
|
raise HTTPException(400, f"assets: {name}: data is not base64")
|
|
if not data:
|
|
raise HTTPException(400, f"assets: {name}: empty")
|
|
if len(data) > MAX_UPLOAD_BYTES:
|
|
raise HTTPException(413, f"assets: {name} exceeds {MAX_UPLOAD_BYTES // (1 << 20)} MB")
|
|
dest.mkdir(parents=True, exist_ok=True)
|
|
target = dest / name
|
|
i = 1
|
|
while target.exists():
|
|
target = dest / f"{target.stem}-{i}{pathlib.Path(name).suffix}"
|
|
i += 1
|
|
target.write_bytes(data)
|
|
urls[str(a.get("name") or name)] = f"/api/upload-file?path={batch}/{target.name}"
|
|
return urls
|