1import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as Headers from "effect/http/Headers";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import { ApnsEnvironment, type ApnsCredentials } from "../Config.ts";
11import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts";
12import type { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
13import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
14
15export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts";
16
17const LIVE_ACTIVITY_NAME = "AgentActivity";
18// Bound sending and reading separately so neither stage can hold a batch open.
19const APNS_HTTP_STAGE_TIMEOUT = "10 seconds";
20// Updates only flow on domain events, so a healthy agent can be silent for
21// minutes (long tool calls, pending approvals). Two minutes made iOS dim
22// perfectly healthy activities; ten minutes still bounds how long a dead
23// environment can look alive.
24const STALE_AFTER_SECONDS = 10 * 60;
25const DISMISS_AFTER_SECONDS = 5 * 60;
26// An end without a final content-state leaves whatever the card last showed
27// frozen on the lock screen until dismissal — get it off quickly instead of
28// parading stale state for the full window.
29const CONTENTLESS_DISMISS_AFTER_SECONDS = 15;
30
31const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]);
32export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type;
33
34const ApnsRequestKindSchema = Schema.Literals(["live-activity", "push-notification"]);
35
36interface ApnsLiveActivityRequest {
37 readonly token: string;
38 readonly event: ApnsLiveActivityEvent;
39 readonly priority: "5" | "10";
40 readonly payload: unknown;
41}
42
43interface ApnsPushNotificationRequest {
44 readonly token: string;
45 readonly priority: "10";
46 readonly payload: unknown;
47}
48
49export interface ApnsDeliveryResult {
50 readonly ok: boolean;
51 readonly status: number;
52 readonly reason?: string;
53 readonly apnsId: string | null;
54}
55
56export class ApnsHttpRequestError extends Schema.TaggedError<ApnsHttpRequestError>()(
57 "ApnsHttpRequestError",
58 {
59 requestKind: ApnsRequestKindSchema,
60 event: Schema.NullOr(ApnsLiveActivityEventSchema),
61 environment: ApnsEnvironment,
62 bundleId: Schema.String,
63 tokenSuffix: Schema.String,
64 stage: Schema.Literals(["send", "read-response"]),
65 status: Schema.NullOr(Schema.Number),
66 cause: Schema.Defect(),
67 },
68) {
69 override get message(): string {
70 return `APNs ${this.requestKind} request failed during ${this.stage} in ${this.environment}.`;
71 }
72}
73
74export type ApnsError = ApnsJwtEncodingError | ApnsJwtSigningError | ApnsHttpRequestError;
75
76const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(
77 Schema.fromJsonString(
78 Schema.Struct({
79 reason: Schema.optional(Schema.String),
80 }),
81 ),
82);
83function contentState(state: RelayAgentActivityAggregateState) {
84 return {
85 name: LIVE_ACTIVITY_NAME,
86 props: JSON.stringify(state),
87 };
88}
89
90interface LiveActivityRequestBase {
91 readonly token: string;
92 readonly nowEpochSeconds: number;
93 readonly nowIso: string;
94}
95
96type MakeLiveActivityRequestInput =
97 | (LiveActivityRequestBase & {
98 readonly event: "end";
99 readonly state: RelayAgentActivityAggregateState | null;
100 readonly alert?: ApnsLiveActivityAlert | null;
101 })
102 | (LiveActivityRequestBase & {
103 readonly event: "start" | "update";
104 readonly state: RelayAgentActivityAggregateState;
105 readonly alert?: ApnsLiveActivityAlert | null;
106 });
107
108// An alert dict on an update/end makes it an "alerting" update: iOS wakes the
109// screen and plays the haptic (the Apple Sports score-change behavior) instead
110// of silently redrawing the activity.
111function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) {
112 return {
113 alert: {
114 title: alert.title,
115 body: alert.body,
116 sound: "default",
117 },
118 };
119}
120
121function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest {
122 const timestamp = input.nowEpochSeconds;
123 if (input.event === "end") {
124 return {
125 token: input.token,
126 event: input.event,
127 priority: "10",
128 payload: {
129 aps: {
130 timestamp,
131 event: "end",
132 ...(input.state ? { "content-state": contentState(input.state) } : {}),
133 ...(input.alert ? liveActivityAlertPayload(input.alert) : {}),
134 "dismissal-date":
135 timestamp + (input.state ? DISMISS_AFTER_SECONDS : CONTENTLESS_DISMISS_AFTER_SECONDS),
136 },
137 },
138 };
139 }
140
141 const state = input.state;
142 return {
143 token: input.token,
144 event: input.event,
145 // Alerting updates must land immediately; routine redraws stay at the
146 // budget-friendly low priority.
147 priority: input.event === "update" && !input.alert ? "5" : "10",
148 payload: {
149 aps: {
150 timestamp,
151 event: input.event,
152 ...(input.event === "start"
153 ? {
154 "attributes-type": "LiveActivityAttributes",
155 attributes: {},
156 "input-push-token": 1,
157 alert: {
158 title: state.title,
159 body: state.subtitle,
160 },
161 }
162 : {}),
163 ...(input.event === "update" && input.alert ? liveActivityAlertPayload(input.alert) : {}),
164 "content-state": contentState(state),
165 "stale-date": timestamp + STALE_AFTER_SECONDS,
166 },
167 },
168 };
169}
170
171function notificationThreadId(notification: ApnsNotificationPayload): string {
172 return notification.threadId.length > 0
173 ? `${notification.environmentId}/${notification.threadId}`
174 : "t3-agent-alerts";
175}
176
177function makePushNotificationRequest(input: {
178 readonly token: string;
179 readonly notification: ApnsNotificationPayload;
180}): ApnsPushNotificationRequest {
181 return {
182 token: input.token,
183 priority: "10",
184 payload: {
185 aps: {
186 alert: {
187 title: input.notification.title,
188 body: input.notification.body,
189 },
190 sound: "default",
191 // Notification Center stacks alerts by thread so a chatty thread does
192 // not bury the others; a grouped alert for several threads stays alone.
193 "thread-id": notificationThreadId(input.notification),
194 },
195 environmentId: input.notification.environmentId,
196 threadId: input.notification.threadId,
197 deepLink: input.notification.deepLink,
198 },
199 };
200}
201
202function apnsReasonFromBody(body: string): string | undefined {
203 if (body.trim().length === 0) {
204 return undefined;
205 }
206 return Option.match(decodeApnsErrorResponseJson(body), {
207 onNone: () => body,
208 onSome: (parsed) => parsed.reason ?? body,
209 });
210}
211
212export class ApnsClient extends Context.Service<
213 ApnsClient,
214 {
215 readonly makeLiveActivityRequest: typeof makeLiveActivityRequest;
216 readonly makePushNotificationRequest: typeof makePushNotificationRequest;
217 readonly sendLiveActivityRequest: (input: {
218 readonly credentials: ApnsCredentials;
219 readonly request: ApnsLiveActivityRequest;
220 readonly issuedAtUnixSeconds: number;
221 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
222 readonly sendPushNotificationRequest: (input: {
223 readonly credentials: ApnsCredentials;
224 readonly request: ApnsPushNotificationRequest;
225 readonly issuedAtUnixSeconds: number;
226 }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;
227 }
228>()("t3code-relay/agentActivity/ApnsClient") {}
229
230export const make = Effect.gen(function* () {
231 const httpClient = yield* HttpClient.HttpClient;
232 const providerTokens = yield* ApnsProviderTokens.ApnsProviderTokens;
233
234 const sendLiveActivityRequest: ApnsClient["Service"]["sendLiveActivityRequest"] = Effect.fn(
235 "relay.apns.send_live_activity_request",
236 )(function* (input) {
237 yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event });
238 const jwt = yield* providerTokens.getJwt({
239 ...input.credentials,
240 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
241 });
242 const host =
243 input.credentials.environment === "production"
244 ? "https://api.push.apple.com"
245 : "https://api.sandbox.push.apple.com";
246 const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe(
247 HttpClientRequest.setHeaders({
248 authorization: `bearer ${jwt}`,
249 "apns-priority": input.request.priority,
250 "apns-push-type": "liveactivity",
251 "apns-topic": `${input.credentials.bundleId}.push-type.liveactivity`,
252 }),
253 HttpClientRequest.bodyJson(input.request.payload),
254 Effect.flatMap(httpClient.execute),
255 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
256 Effect.mapError(
257 (cause) =>
258 new ApnsHttpRequestError({
259 requestKind: "live-activity",
260 event: input.request.event,
261 environment: input.credentials.environment,
262 bundleId: input.credentials.bundleId,
263 tokenSuffix: input.request.token.slice(-8),
264 stage: "send",
265 status: null,
266 cause,
267 }),
268 ),
269 );
270 const responseText = yield* response.text.pipe(
271 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
272 Effect.mapError(
273 (cause) =>
274 new ApnsHttpRequestError({
275 requestKind: "live-activity",
276 event: input.request.event,
277 environment: input.credentials.environment,
278 bundleId: input.credentials.bundleId,
279 tokenSuffix: input.request.token.slice(-8),
280 stage: "read-response",
281 status: response.status,
282 cause,
283 }),
284 ),
285 );
286 const reason = apnsReasonFromBody(responseText);
287 return {
288 ok: response.status >= 200 && response.status < 300,
289 status: response.status,
290 ...(reason === undefined ? {} : { reason }),
291 apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")),
292 };
293 });
294
295 const sendPushNotificationRequest: ApnsClient["Service"]["sendPushNotificationRequest"] =
296 Effect.fn("relay.apns.send_push_notification_request")(function* (input) {
297 yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" });
298 const jwt = yield* providerTokens.getJwt({
299 ...input.credentials,
300 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
301 });
302 const host =
303 input.credentials.environment === "production"
304 ? "https://api.push.apple.com"
305 : "https://api.sandbox.push.apple.com";
306 const response = yield* HttpClientRequest.post(
307 `${host}/3/device/${input.request.token}`,
308 ).pipe(
309 HttpClientRequest.setHeaders({
310 authorization: `bearer ${jwt}`,
311 "apns-priority": input.request.priority,
312 "apns-push-type": "alert",
313 "apns-topic": input.credentials.bundleId,
314 }),
315 HttpClientRequest.bodyJson(input.request.payload),
316 Effect.flatMap(httpClient.execute),
317 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
318 Effect.mapError(
319 (cause) =>
320 new ApnsHttpRequestError({
321 requestKind: "push-notification",
322 event: null,
323 environment: input.credentials.environment,
324 bundleId: input.credentials.bundleId,
325 tokenSuffix: input.request.token.slice(-8),
326 stage: "send",
327 status: null,
328 cause,
329 }),
330 ),
331 );
332 const responseText = yield* response.text.pipe(
333 Effect.timeout(APNS_HTTP_STAGE_TIMEOUT),
334 Effect.mapError(
335 (cause) =>
336 new ApnsHttpRequestError({
337 requestKind: "push-notification",
338 event: null,
339 environment: input.credentials.environment,
340 bundleId: input.credentials.bundleId,
341 tokenSuffix: input.request.token.slice(-8),
342 stage: "read-response",
343 status: response.status,
344 cause,
345 }),
346 ),
347 );
348 const reason = apnsReasonFromBody(responseText);
349 return {
350 ok: response.status >= 200 && response.status < 300,
351 status: response.status,
352 ...(reason === undefined ? {} : { reason }),
353 apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")),
354 };
355 });
356
357 return ApnsClient.of({
358 makeLiveActivityRequest,
359 makePushNotificationRequest,
360 sendLiveActivityRequest,
361 sendPushNotificationRequest,
362 });
363});
364
365export const layer = Layer.effect(ApnsClient, make);