Files
iris_x_hermes/docs/08-push.md
T
ARIA 746d809d48
CI / Gateway plugin tests (push) Successful in 5m3s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m6s
Default push backend to ntfy; FCM opt-in with privacy warning (issue #10)
- IRIS_PUSH_BACKEND now defaults to ntfy (keeps push metadata on your own
  infrastructure); FCM is opt-in via IRIS_PUSH_BACKEND=fcm
- build_push_backend(): ntfy for empty/unknown names, FCM only on explicit 'fcm'
- gateway setup: warn when FCM is chosen (metadata routed via Google's servers)
- README: privacy note + dedicated push section; new docs/playstore-listing.md
  with the FCM/ntfy privacy note for the Play Store listing
- docs: 00/02/03/08/12/16 + setup.md updated to ntfy-default wording
- tests: default-backend assertion updated (86/86 pass)
2026-08-24 19:04:40 +02:00

172 lines
8.5 KiB
Markdown

# 08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
Privacy: FCM push metadata (notification title, device token) is routed
through Google's servers — for truly private communication use ntfy
(self-hosted), which keeps everything on your own infrastructure.
## 8.1 When push fires
- A frame targets a `chat_id` whose device is **disconnected** (no live
SSE/long-poll subscriber) → drop to **outbox** + fire **push** — with one
refinement, *turn-aware push* (below).
- Also fire push for high-priority foreground events the user should see even if
the app is backgrounded (approvals, clarifies, cron completions) — the app
decides whether to also show an in-app banner.
- If the device is **connected**, no push (the live frame is enough).
### 8.1.1 Turn-aware push (one push per turn, final answer as body)
An agent turn can span minutes and emit several completed status messages
("researching X…", "found Y…", "writing findings…", final answer). Pushing each
parked message would spam an offline user with the steps in between. So while
the agent's turn is **in flight** (hermes holds the typing indicator on from
turn start until the handler's `finally` at turn end), normal-priority
`message` / `message.stop` / `media.offer` frames that park with no live
device are **held back** per chat instead of pushing; the latest one is pushed
when the turn ends (`stop_typing`), so the offline user gets **one push with
the final answer**. Details:
- Turn state is tracked per `chat_id` from the typing indicator
(`send_typing` → in flight, `stop_typing` → ended; hermes fires
`stop_typing` in the handler's `finally`, after the final send, so the
flush always sees the final frame).
- The held-back frame is still parked in the outbox — sync catch-up is
unaffected; only the push is deferred.
- **High-priority notifications** (approval/clarify/cron) push immediately,
even mid-turn — they need user action.
- If the device **reconnects mid-turn** (SSE/long-poll open), the held-back
push is dropped: the app syncs the parked frames and must not get a
duplicate push at turn end.
- If the turn ends while the device is live, nothing is pushed (the frames
were delivered live / synced).
- Turns without a typing indicator (e.g. typing disabled in config) and
non-turn deliveries (cron, standalone sends) push immediately as before.
- Best-effort: a gateway crash mid-turn loses the held-back push (the frames
remain in the outbox and sync on reconnect).
## 8.2 `PushBackend` interface (`push.py`)
```python
class PushBackend(Protocol):
name: str
async def send(self, *, device_id: str, chat_id: str,
title: str, body: str,
data: dict) -> bool: ...
def configured(self) -> bool: ...
```
Selected at adapter init by `IRIS_PUSH_BACKEND` (`ntfy` default, `fcm`).
### 8.2.1 `FcmBackend` (optional; metadata via Google)
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
OAuth2 access token (cached, refreshed before expiry).
- Fallback: legacy **server key** (`IRIS_FCM_SERVER_KEY`) if no service
account (simpler, but legacy).
- Target = the device's **FCM token** (registered via `hello` /
`fcm.register`, stored in `devices.db`).
- Payload:
- `notification {title, body}` → system notification (tap → open app).
- `data {chat_id, thread_id, kind, message_id, cursor}` → app uses to `sync`.
- **Data-only option:** for background catch-up, send a data message (silent) so
the app's `FirebaseMessagingService` wakes a foreground service and syncs
without a visible notification (used for non-urgent updates).
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
devices).
### 8.2.2 `NtfyBackend` (default, self-host friendly)
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
- Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
`httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a
JSON `data` attachment (`{chat_id, thread_id, kind, cursor}`).
- The app subscribes to the topic (via an ntfy client lib or a lightweight
listener) to receive pushes without Firebase.
- Best for users who self-host ntfy and want zero Firebase.
## 8.3 Outbox (`outbox.py`)
- SQLite `outbox.db`: rows `{cursor (monotonic), chat_id, thread_id, frame_json,
ts, delivered}`.
- **Write** on every outbound frame that has no live subscriber (or always, then
mark delivered on live send — simpler + crash-safe).
- **Retention:** `outbox_retention_hours` (default 72h); prune on write.
- **Cursor:** global monotonic int; `hello.ack` returns the current cursor;
`sync {cursor}` replays rows with `cursor > given`.
- Bounded: if the outbox grows past a cap (e.g. 5k rows), oldest are pruned and
a `notification {kind:"generic", body:"Older messages pruned"}` is sent.
## 8.4 Sync (reconnect catch-up)
1. App reconnects → `hello` → `hello.ack {sync_cursor}`.
2. App sends `sync {cursor: <last seen>}`.
3. Server replays outbox frames (in cursor order) → app applies them (messages,
tools, channels, media offers).
4. Server sends `sync.done {cursor: <new>}`.
5. App updates its local cursor (Room) — idempotent (dedupe by `message_id`).
This makes the app **eventually consistent** across disconnects, restarts, and
gateway restarts.
## 8.5 App-side push handling (Android)
- **`FirebaseMessagingService.onMessageReceived`** (foreground): show in-app
banner + optionally sync.
- **`onNewToken`** → `fcm.register` the new token.
- **Background data message** → start a **foreground service** (low-priority,
notification channel "Sync") → open WS → `sync` → stop.
- **Notification channels** (Android 8+): one per chat so cron channels can have
their own style/priority (e.g. "Cron Reports" = high, "Default" = default).
Tapping a notification deep-links to the chat/thread.
- **ntfy mode:** a foreground service maintains the ntfy subscription; incoming
events trigger the same sync path.
## 8.6 In-app banners (foreground)
`notification` frames (e.g. `channel_renamed`, `approval`, `clarify`, `cron`)
render as a transient banner above the composer (like the reference screenshot's
"ARIA hat Thema … umbenannt"). Dismissible; high-priority ones (approval/clarify)
persist until acted on.
## 8.7 Security
- FCM tokens are per-device, revocable; stored only in `devices.db`.
- Push payloads carry **no secrets** and no full message bodies larger than a
short preview (privacy on lock screen). Full content is fetched via `sync`
over the authenticated WS.
- ntfy: use a **private topic + auth token** for any real trust boundary (hermes
ntfy adapter guidance).
## 8.8 Notification dedupe (push vs. sync)
A message sent while the device is offline is notified **twice** by naive
design: once by the push (FCM displays the `notification` payload), and again
when the app reconnects, syncs the outbox, and mirrors the replayed frames to
system notifications (the background-mirror path, §8.5). The fix is a per-
device push watermark:
- **Gateway** records the highest outbox cursor delivered to each device via
the push backend (`devices.last_pushed_cursor`, advanced only on a
*successful* send) and returns it in `hello.ack` as `last_pushed_cursor`.
- **Gateway** coalesces back-to-back pushes per chat (5 s window): a cron
delivery parks a notification frame AND a message frame, and only the first
pushes — the second reaches the app via sync (tap the first notification).
- **Gateway** tags every `sync`-replayed frame with its outbox cursor in the
frame envelope (`cursor`; live frames carry none).
- **App** skips system notifications for replayed frames with
`cursor <= lastPushedCursor` (they already woke the device). Live frames are
never suppressed — that is exactly the case where no push fired and the app
must notify itself.
- **App** FCM handler (`onMessageReceived`) posts nothing when the WS is
connected (the background-mirror path handles it) and nothing when the
message carried a `notification` payload (FCM already displayed it);
data-only messages are the exception (the app must display them itself).
Residual edge: FCM is at-least-once, so a lost device ack can still produce a
duplicate *system-displayed* notification (two `FCM-Notification:*` ids). The
designed evolution is the data-only push option (§8.2.1), which moves display
into the app and lets it use a stable per-message notification id.