// Delivers pushes to paired watches through APNs, and remembers Apple's last answer so the watch's // status screen can show whether pushes are getting through. Each watch only gets the kinds of push // it asked for. Without APNs settings it only logs. import type { Apns } from "./apns.ts"; import type { NotificationPrefs, PushResult } from "./contract.ts"; import type { Db, DeviceRow } from "./db.ts"; import type { Push } from "./notify.ts"; const ALL_ON: NotificationPrefs = { needsMe: true, finished: true, failed: true, subagents: true }; /** A watch's choices, with every kind it never set left on. */ export function notificationPrefs(device: DeviceRow): NotificationPrefs { const saved: Partial = device.notifications ? JSON.parse(device.notifications) : {}; return { ...ALL_ON, ...saved }; } /** Whether a watch with these choices wants this push. Host problems and tests always go out. */ export function wants(prefs: NotificationPrefs, push: Push): boolean { switch (push.category) { case "approval": case "input": return push.fromSubagent ? prefs.subagents : prefs.needsMe; case "finished": return prefs.finished; case "failed": return prefs.failed; default: return true; } } export class Pusher { /** Apple's answer to the most recent push sent to any watch. */ last: PushResult | null = null; constructor( private readonly db: Db, private readonly apns: Apns | null, private readonly log: (message: string) => void = () => {}, ) {} get configured() { return this.apns !== null; } /** Sends to every paired watch that registered for pushes and wants this kind. */ sendAll = async (push: Push): Promise => { const devices = this.db.devices .active() .filter((device) => device.apns_token && device.apns_env && wants(notificationPrefs(device), push)); if (!this.apns || devices.length === 0) { this.log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`); return; } await Promise.all(devices.map((device) => this.sendTo(device, push))); }; /** Sends to one watch and returns what Apple said. */ async sendTo(device: DeviceRow, push: Push): Promise { const at = new Date().toISOString(); if (!this.apns) return { ok: false, at, status: 0, reason: "The gateway has no APNs key configured." }; if (!device.apns_token || !device.apns_env) { return { ok: false, at, status: 0, reason: "This watch has not registered for notifications." }; } const payload = { aps: { alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body: push.body }, sound: "default", category: push.category, "thread-id": push.data.threadId ?? push.data.hostId ?? push.category, }, ...push.data, }; const answer = await this.apns.send({ deviceToken: device.apns_token, environment: device.apns_env, payload, collapseId: push.collapseId, }); const result: PushResult = answer.ok ? { ok: true, at, status: 200, reason: null } : { ok: false, at, status: answer.status, reason: answer.reason }; this.last = result; if (!answer.ok) { this.log(`APNs refused a push to ${device.name}: ${answer.status} ${answer.reason}`); if (answer.tokenInvalid) this.db.devices.clearPush(device.apns_token); } return result; } }