Notifications follow the conversation's audience (users/access.audience):
whoever started it and the people it's shared with, plus the owner once they
follow a guest's conversation (writing in it, or PUT /api/conversation-follow),
minus whoever muted it (meta.follow). Pushes and asks fan out to each of them
on their own channels — the owner on the webhooks, a guest in-app + email —
and the feeds (/api/notifications, /api/notify-log, the phone's /unread)
list only what the caller hears, links rebased to the reader's host. Forms
carry forYou so "waiting for feedback" is per user.
/api/users carries spentUsd (this month), totalSpentUsd and conversations per
guest (users/runs.spend_by_user, the same sum the budget is checked against).
POST /api/users/{id}/impersonate opens a 4-hour guest session for the owner's
browser on the admin host (imp on the record: never extended, not counted in
the guest's sessions); /api/me says impersonating; logout ends it. A spawn
while impersonating skips the "guest started" ping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
4.3 KiB
Python
110 lines
4.3 KiB
Python
"""
|
|
Who a push, ask or form reaches — the conversation's **audience**
|
|
(``users/access.audience``): whoever started it, the people it's shared with,
|
|
the owner once they follow a guest's conversation, minus whoever muted it.
|
|
Each person is reached on their own channels:
|
|
|
|
* the **owner** — the webhooks (Home Assistant → the phone), as always;
|
|
* a **guest** — 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).
|
|
|
|
A push with no session (a host script, the owner's guest-start ping) is the
|
|
owner's. Besides their own conversations, 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 access, mailer
|
|
from users.caller import OWNER_ID
|
|
|
|
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 recipients(deps, session_id: str) -> list[str]:
|
|
"""The user ids a push from ``session_id`` goes to (``owner`` included)."""
|
|
sid = (session_id or "").strip()
|
|
if not sid:
|
|
return [OWNER_ID]
|
|
return access.audience(deps.meta_store.peek(sid))
|
|
|
|
|
|
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 url_for(caller, url: str, view_url: str) -> str:
|
|
"""A recorded link as ``caller`` can open it: on the guest surface for a
|
|
guest, on the admin host for the owner (older guest pushes stored the
|
|
guest host's link)."""
|
|
if not url:
|
|
return url
|
|
if not caller.is_owner:
|
|
return guest_url(url, view_url)
|
|
if GUEST_PUBLIC_URL and url.startswith(GUEST_PUBLIC_URL):
|
|
return view_url + url[len(GUEST_PUBLIC_URL):]
|
|
return url
|
|
|
|
|
|
def to_guests(deps, to: list[str], title: str, message: str, url: str,
|
|
view_url: str) -> list[dict]:
|
|
"""Fan a push out to every guest in ``to`` — each delivery record tagged
|
|
with its ``user``."""
|
|
out: list[dict] = []
|
|
for uid in to:
|
|
if uid == OWNER_ID:
|
|
continue
|
|
user = deps.users.active(uid)
|
|
if user:
|
|
out += [{**r, "user": uid} for r in
|
|
to_guest(user, title, message, guest_url(url, view_url))]
|
|
return out
|
|
|
|
|
|
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)
|