Files
ai-agent/backend/users/deliver.py
Gabriel Vidal 3d741b5b1e feat(backend): per-user notifications, guest spend on the users list, impersonation
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>
2026-10-12 22:16:03 +02:00

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)