| 1 | import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import * as Headers from "effect/http/Headers"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 10 | import { ApnsEnvironment, type ApnsCredentials } from "../Config.ts"; |
| 11 | import type { ApnsLiveActivityAlert, ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; |
| 12 | import type { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; |
| 13 | import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; |
| 14 | |
| 15 | export { ApnsJwtEncodingError, ApnsJwtSigningError } from "./apnsJwt.ts"; |
| 16 | |
| 17 | const LIVE_ACTIVITY_NAME = "AgentActivity"; |
| 18 | // Bound sending and reading separately so neither stage can hold a batch open. |
| 19 | const 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. |
| 24 | const STALE_AFTER_SECONDS = 10 * 60; |
| 25 | const 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. |
| 29 | const CONTENTLESS_DISMISS_AFTER_SECONDS = 15; |
| 30 | |
| 31 | const ApnsLiveActivityEventSchema = Schema.Literals(["start", "update", "end"]); |
| 32 | export type ApnsLiveActivityEvent = typeof ApnsLiveActivityEventSchema.Type; |
| 33 | |
| 34 | const ApnsRequestKindSchema = Schema.Literals(["live-activity", "push-notification"]); |
| 35 | |
| 36 | interface ApnsLiveActivityRequest { |
| 37 | readonly token: string; |
| 38 | readonly event: ApnsLiveActivityEvent; |
| 39 | readonly priority: "5" | "10"; |
| 40 | readonly payload: unknown; |
| 41 | } |
| 42 | |
| 43 | interface ApnsPushNotificationRequest { |
| 44 | readonly token: string; |
| 45 | readonly priority: "10"; |
| 46 | readonly payload: unknown; |
| 47 | } |
| 48 | |
| 49 | export interface ApnsDeliveryResult { |
| 50 | readonly ok: boolean; |
| 51 | readonly status: number; |
| 52 | readonly reason?: string; |
| 53 | readonly apnsId: string | null; |
| 54 | } |
| 55 | |
| 56 | export 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 | |
| 74 | export type ApnsError = ApnsJwtEncodingError | ApnsJwtSigningError | ApnsHttpRequestError; |
| 75 | |
| 76 | const decodeApnsErrorResponseJson = Schema.decodeUnknownOption( |
| 77 | Schema.fromJsonString( |
| 78 | Schema.Struct({ |
| 79 | reason: Schema.optional(Schema.String), |
| 80 | }), |
| 81 | ), |
| 82 | ); |
| 83 | function contentState(state: RelayAgentActivityAggregateState) { |
| 84 | return { |
| 85 | name: LIVE_ACTIVITY_NAME, |
| 86 | props: JSON.stringify(state), |
| 87 | }; |
| 88 | } |
| 89 | |
| 90 | interface LiveActivityRequestBase { |
| 91 | readonly token: string; |
| 92 | readonly nowEpochSeconds: number; |
| 93 | readonly nowIso: string; |
| 94 | } |
| 95 | |
| 96 | type 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. |
| 111 | function liveActivityAlertPayload(alert: ApnsLiveActivityAlert) { |
| 112 | return { |
| 113 | alert: { |
| 114 | title: alert.title, |
| 115 | body: alert.body, |
| 116 | sound: "default", |
| 117 | }, |
| 118 | }; |
| 119 | } |
| 120 | |
| 121 | function 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 | |
| 171 | function notificationThreadId(notification: ApnsNotificationPayload): string { |
| 172 | return notification.threadId.length > 0 |
| 173 | ? `${notification.environmentId}/${notification.threadId}` |
| 174 | : "t3-agent-alerts"; |
| 175 | } |
| 176 | |
| 177 | function 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 | |
| 202 | function 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 | |
| 212 | export 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 | |
| 230 | export 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 | |
| 365 | export const layer = Layer.effect(ApnsClient, make); |