109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
// 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";
|
|
import { trimText } from "./views.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<NotificationPrefs> = 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;
|
|
}
|
|
}
|
|
|
|
/** APNs refuses a payload over 4096 bytes. This leaves room for the JSON APNs adds. */
|
|
const PAYLOAD_LIMIT = 3900;
|
|
|
|
/**
|
|
* The APNs payload for a push, kept under APNs' size limit. Long titles and bodies in multi-byte
|
|
* scripts can overflow it, so the question's buttons go first, then the body is cut.
|
|
*/
|
|
export function apnsPayload(push: Push) {
|
|
const build = (data: Push["data"], body: string) => ({
|
|
aps: {
|
|
alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body },
|
|
sound: "default",
|
|
category: push.category,
|
|
"thread-id": data.threadId ?? data.hostId ?? push.category,
|
|
},
|
|
...data,
|
|
});
|
|
const fits = (payload: object) => Buffer.byteLength(JSON.stringify(payload)) <= PAYLOAD_LIMIT;
|
|
let payload = build(push.data, push.body);
|
|
if (fits(payload)) return payload;
|
|
const { question: _, ...plain } = push.data;
|
|
payload = build(plain, push.body);
|
|
for (let chars = 120; !fits(payload) && chars >= 20; chars -= 50) payload = build(plain, trimText(push.body, chars));
|
|
return payload;
|
|
}
|
|
|
|
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<void> => {
|
|
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<PushResult> {
|
|
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 = apnsPayload(push);
|
|
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;
|
|
}
|
|
}
|