Files
ai-agent/backend/users/deliver.py
Gabriel Vidal 81c455defa feat(users): scope every guest-reachable route to the caller
A guest sees the conversations they started or were shared (list,
detail, summary, search index, subagents, raw meta, diffs), the forms,
asks and pushes of those, and the projects they were granted. Their
ui-state lives under u.<id>.<key>; the SSE hub filters per subscriber.
Forms record answeredBy. Pushes from a guest's session skip the owner's
webhooks and go to the guest's channels (in-app, email).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:27:23 +02:00

80 lines
3.1 KiB
Python

"""
Where a push from a **guest's** session goes.
The owner's pushes go to the owner's webhooks (Home Assistant → the phone).
A guest's run sends the same ``notify`` / ``ask`` / ``form`` calls, but those
are for the guest: the owner's webhooks are skipped and the guest is reached on
the channels they chose (``users.json`` → ``notify.channels``):
* ``in_app`` — always: the feed and the waiting-for-feedback list are filtered
per user, and the SSE ping reaches their open tabs.
* ``email`` — a short mail with the link, sent off the request thread.
* ``webpush`` — recorded as a preference; delivery needs VAPID keys and the
service worker's push handler (not built yet).
The owner hears about a guest only when one *starts* a conversation
(:func:`owner_ping_start`).
"""
from __future__ import annotations
import logging
import os
import threading
from users import mailer
log = logging.getLogger("ai-agent.deliver")
# The guest surface's public origin — what links in a guest's mail point at.
GUEST_PUBLIC_URL = os.environ.get("GUEST_PUBLIC_URL", "").rstrip("/")
def guest_of(deps, session_id: str) -> dict | None:
"""The guest user record owning ``session_id`` (None = the owner's)."""
sid = (session_id or "").strip()
if not sid:
return None
uid = deps.meta_store.peek(sid).get("owner")
return deps.users.get(uid) if uid else None
def guest_url(owner_url: str, view_url: str) -> str:
"""Rebase a link from the owner's viewer onto the guest surface."""
if GUEST_PUBLIC_URL and owner_url.startswith(view_url):
return GUEST_PUBLIC_URL + owner_url[len(view_url):]
return owner_url
def to_guest(user: dict, title: str, message: str, url: str) -> list[dict]:
"""Fan a push out to the guest's channels; returns delivery records shaped
like the webhook ones (``{target, ok, …}``)."""
channels = (user.get("notify") or {}).get("channels") or ["in_app"]
out = [{"target": "in_app", "ok": True}]
if "email" in channels and mailer.enabled():
threading.Thread(target=_mail, args=(user, title, message, url),
daemon=True).start()
out.append({"target": "email", "ok": True, "queued": True})
return out
def _mail(user: dict, title: str, message: str, url: str) -> None:
text = f"{message}\n\n{url}\n" if message else f"{url}\n"
try:
mailer.send(user["email"], title or "Update from your conversation", text)
except mailer.MailError as exc:
log.warning("guest mail to %s failed: %s", user.get("id"), exc)
def owner_ping_start(deps, caller, slug: str, prompt: str) -> None:
"""The one push the owner gets about a guest: they started something."""
from notifications.service import VIEW_URL, _create_notify
first = " ".join((prompt or "").split())[:160]
try:
_create_notify(deps, {
"title": f"👤 {caller.name} started a conversation",
"message": f"{slug} · {first}",
"type": "ai", "url": f"{VIEW_URL}/conversations", "sessionId": ""})
except Exception as exc: # never fail the spawn over the ping
log.warning("owner ping failed: %s", exc)