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>
80 lines
3.1 KiB
Python
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)
|