320 lines · 10.0 KB · TypeScript
RawDownload
import * as NodeCrypto from "node:crypto"; import type { RelayAgentActivityAggregateState } from "@t3tools/contracts/relay";import * as Context from "effect/Context";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Redacted from "effect/Redacted";import * as Schema from "effect/Schema";import {  Headers,  HttpClient,  HttpClientRequest,  type HttpBody,  type HttpClientError,} from "effect/unstable/http";import type { ApnsCredentials } from "../Config.ts";import type { ApnsNotificationPayload } from "./apnsDeliveryJobs.ts"; const LIVE_ACTIVITY_NAME = "AgentActivity";const STALE_AFTER_SECONDS = 2 * 60;const DISMISS_AFTER_SECONDS = 5 * 60; export type ApnsLiveActivityEvent = "start" | "update" | "end"; interface ApnsLiveActivityRequest {  readonly token: string;  readonly event: ApnsLiveActivityEvent;  readonly priority: "5" | "10";  readonly payload: unknown;} interface ApnsPushNotificationRequest {  readonly token: string;  readonly priority: "10";  readonly payload: unknown;} export interface ApnsDeliveryResult {  readonly ok: boolean;  readonly status: number;  readonly reason?: string;  readonly apnsId: string | null;} export class ApnsSigningError extends Data.TaggedError("ApnsSigningError")<{  readonly phase: "encoding" | "signing";  readonly cause: unknown;}> {} export class ApnsHttpRequestError extends Data.TaggedError("ApnsHttpRequestError")<{  readonly cause: HttpClientError.HttpClientError | HttpBody.HttpBodyError;}> {} export class ApnsInvalidResponseError extends Data.TaggedError("ApnsInvalidResponseError")<{  readonly cause: unknown;}> {} export type ApnsError = ApnsSigningError | ApnsHttpRequestError | ApnsInvalidResponseError; const decodeApnsErrorResponseJson = Schema.decodeUnknownOption(  Schema.fromJsonString(    Schema.Struct({      reason: Schema.optional(Schema.String),    }),  ),);const encodeApnsJwtHeaderJson = Schema.encodeEffect(  Schema.fromJsonString(    Schema.Struct({      alg: Schema.Literal("ES256"),      kid: Schema.String,    }),  ),);const encodeApnsJwtPayloadJson = Schema.encodeEffect(  Schema.fromJsonString(    Schema.Struct({      iss: Schema.String,      iat: Schema.Number,    }),  ),); const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: {  readonly teamId: ApnsCredentials["teamId"];  readonly keyId: ApnsCredentials["keyId"];  readonly privateKey: ApnsCredentials["privateKey"];  readonly issuedAtUnixSeconds: number;}) {  const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe(    Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" })),  );  const payloadJson = yield* encodeApnsJwtPayloadJson({    iss: input.teamId,    iat: input.issuedAtUnixSeconds,  }).pipe(Effect.mapError((cause) => new ApnsSigningError({ cause, phase: "encoding" })));   const privateKey = Redacted.value(input.privateKey);  const header = Encoding.encodeBase64Url(headerJson);  const payload = Encoding.encodeBase64Url(payloadJson);  const signingInput = `${header}.${payload}`;   return yield* Effect.try({    try: () => {      const signature = NodeCrypto.createSign("sha256")        .update(signingInput)        .sign({          key: privateKey.replace(/\\n/g, "\n"),          dsaEncoding: "ieee-p1363",        });      return `${signingInput}.${Encoding.encodeBase64Url(signature)}`;    },    catch: (cause) => new ApnsSigningError({ cause, phase: "signing" }),  });}); function contentState(state: RelayAgentActivityAggregateState) {  return {    name: LIVE_ACTIVITY_NAME,    props: JSON.stringify(state),  };} interface LiveActivityRequestBase {  readonly token: string;  readonly nowEpochSeconds: number;  readonly nowIso: string;} type MakeLiveActivityRequestInput =  | (LiveActivityRequestBase & {      readonly event: "end";      readonly state: RelayAgentActivityAggregateState | null;    })  | (LiveActivityRequestBase & {      readonly event: "start" | "update";      readonly state: RelayAgentActivityAggregateState;    }); function makeLiveActivityRequest(input: MakeLiveActivityRequestInput): ApnsLiveActivityRequest {  const timestamp = input.nowEpochSeconds;  if (input.event === "end") {    return {      token: input.token,      event: input.event,      priority: "10",      payload: {        aps: {          timestamp,          event: "end",          ...(input.state ? { "content-state": contentState(input.state) } : {}),          "dismissal-date": timestamp + DISMISS_AFTER_SECONDS,        },      },    };  }   const state = input.state;  return {    token: input.token,    event: input.event,    priority: input.event === "update" ? "5" : "10",    payload: {      aps: {        timestamp,        event: input.event,        ...(input.event === "start"          ? {              "attributes-type": "LiveActivityAttributes",              attributes: {},              "input-push-token": 1,              alert: {                title: state.title,                body: state.subtitle,              },            }          : {}),        "content-state": contentState(state),        "stale-date": timestamp + STALE_AFTER_SECONDS,      },    },  };} function makePushNotificationRequest(input: {  readonly token: string;  readonly notification: ApnsNotificationPayload;}): ApnsPushNotificationRequest {  return {    token: input.token,    priority: "10",    payload: {      aps: {        alert: {          title: input.notification.title,          body: input.notification.body,        },        sound: "default",      },      environmentId: input.notification.environmentId,      threadId: input.notification.threadId,      deepLink: input.notification.deepLink,    },  };} function apnsReasonFromBody(body: string): string | undefined {  if (body.trim().length === 0) {    return undefined;  }  return Option.match(decodeApnsErrorResponseJson(body), {    onNone: () => body,    onSome: (parsed) => parsed.reason ?? body,  });} export interface ApnsClientShape {  readonly makeLiveActivityRequest: typeof makeLiveActivityRequest;  readonly makePushNotificationRequest: typeof makePushNotificationRequest;  readonly sendLiveActivityRequest: (input: {    readonly credentials: ApnsCredentials;    readonly request: ApnsLiveActivityRequest;    readonly issuedAtUnixSeconds: number;  }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;  readonly sendPushNotificationRequest: (input: {    readonly credentials: ApnsCredentials;    readonly request: ApnsPushNotificationRequest;    readonly issuedAtUnixSeconds: number;  }) => Effect.Effect<ApnsDeliveryResult, ApnsError>;} export class ApnsClient extends Context.Service<ApnsClient, ApnsClientShape>()(  "t3code-relay/agentActivity/ApnsClient",) {} const make = Effect.gen(function* () {  const httpClient = yield* HttpClient.HttpClient;   const sendLiveActivityRequest: ApnsClientShape["sendLiveActivityRequest"] = Effect.fn(    "relay.apns.send_live_activity_request",  )(function* (input) {    yield* Effect.annotateCurrentSpan({ "relay.apns.event": input.request.event });    const jwt = yield* makeApnsJwt({      ...input.credentials,      issuedAtUnixSeconds: input.issuedAtUnixSeconds,    });    const host =      input.credentials.environment === "production"        ? "https://api.push.apple.com"        : "https://api.sandbox.push.apple.com";    const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe(      HttpClientRequest.setHeaders({        authorization: `bearer ${jwt}`,        "apns-priority": input.request.priority,        "apns-push-type": "liveactivity",        "apns-topic": `${input.credentials.bundleId}.push-type.liveactivity`,      }),      HttpClientRequest.bodyJson(input.request.payload),      Effect.flatMap(httpClient.execute),      Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),    );    const responseText = yield* response.text.pipe(      Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),    );    const reason = apnsReasonFromBody(responseText);    return {      ok: response.status >= 200 && response.status < 300,      status: response.status,      ...(reason === undefined ? {} : { reason }),      apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")),    };  });   const sendPushNotificationRequest: ApnsClientShape["sendPushNotificationRequest"] = Effect.fn(    "relay.apns.send_push_notification_request",  )(function* (input) {    yield* Effect.annotateCurrentSpan({ "relay.apns.event": "push_notification" });    const jwt = yield* makeApnsJwt({      ...input.credentials,      issuedAtUnixSeconds: input.issuedAtUnixSeconds,    });    const host =      input.credentials.environment === "production"        ? "https://api.push.apple.com"        : "https://api.sandbox.push.apple.com";    const response = yield* HttpClientRequest.post(`${host}/3/device/${input.request.token}`).pipe(      HttpClientRequest.setHeaders({        authorization: `bearer ${jwt}`,        "apns-priority": input.request.priority,        "apns-push-type": "alert",        "apns-topic": input.credentials.bundleId,      }),      HttpClientRequest.bodyJson(input.request.payload),      Effect.flatMap(httpClient.execute),      Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),    );    const responseText = yield* response.text.pipe(      Effect.mapError((cause) => new ApnsHttpRequestError({ cause })),    );    const reason = apnsReasonFromBody(responseText);    return {      ok: response.status >= 200 && response.status < 300,      status: response.status,      ...(reason === undefined ? {} : { reason }),      apnsId: Option.getOrNull(Headers.get(response.headers, "apns-id")),    };  });   return ApnsClient.of({    makeLiveActivityRequest,    makePushNotificationRequest,    sendLiveActivityRequest,    sendPushNotificationRequest,  });}); export const layer = Layer.effect(ApnsClient, make);