1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Redacted from "effect/Redacted";
7import * as Schema from "effect/Schema";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10
11import * as RelayConfiguration from "../Config.ts";
12import * as FcmAssertionSigner from "./FcmAssertionSigner.ts";
13
14const FCM_HTTP_STAGE_TIMEOUT = "10 seconds";
15
16const ServiceAccount = Schema.Struct({
17 project_id: Schema.NonEmptyString,
18 client_email: Schema.NonEmptyString,
19 private_key: Schema.NonEmptyString,
20});
21const decodeServiceAccount = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceAccount));
22const decodeAccessToken = Schema.decodeUnknownEffect(
23 Schema.Struct({ access_token: Schema.NonEmptyString }),
24);
25const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
26const decodeFcmError = Schema.decodeUnknownOption(
27 Schema.Struct({
28 error: Schema.Struct({
29 status: Schema.optional(Schema.String),
30 details: Schema.optional(
31 Schema.Array(
32 Schema.Struct({
33 "@type": Schema.optional(Schema.String),
34 errorCode: Schema.optional(Schema.String),
35 }),
36 ),
37 ),
38 }),
39 }),
40);
41
42export class FcmClientError extends Schema.TaggedError<FcmClientError>()("FcmClientError", {
43 operation: Schema.Literals(["configuration", "authorize", "send"]),
44 status: Schema.NullOr(Schema.Number),
45 cause: Schema.optional(Schema.Defect()),
46}) {
47 override get message() {
48 return `FCM ${this.operation} failed${this.status === null ? "" : ` (${this.status})`}.`;
49 }
50}
51
52export class FcmClient extends Context.Service<
53 FcmClient,
54 {
55 readonly send: (input: {
56 readonly token: string;
57 readonly packageName: string | null;
58 readonly data: Readonly<Record<string, string>>;
59 readonly alert: boolean;
60 }) => Effect.Effect<{ readonly unregistered: boolean }, FcmClientError>;
61 }
62>()("t3code-relay/agentActivity/FcmClient") {}
63
64export const make = Effect.gen(function* () {
65 const config = yield* RelayConfiguration.RelayConfiguration;
66 const signer = yield* FcmAssertionSigner.FcmAssertionSigner;
67 const client = yield* HttpClient.HttpClient;
68 const account = config.fcmServiceAccount
69 ? decodeServiceAccount(Redacted.value(config.fcmServiceAccount))
70 : Option.none();
71 const authorize = Effect.gen(function* () {
72 if (Option.isNone(account))
73 return yield* new FcmClientError({ operation: "configuration", status: null });
74 const now = yield* DateTime.now;
75 const assertion = yield* signer
76 .sign({
77 privateKey: account.value.private_key,
78 clientEmail: account.value.client_email,
79 issuedAt: Math.floor(now.epochMilliseconds / 1000),
80 })
81 .pipe(
82 Effect.mapError(
83 (cause) => new FcmClientError({ operation: "authorize", status: null, cause }),
84 ),
85 );
86 const response = yield* client
87 .execute(
88 HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe(
89 HttpClientRequest.bodyUrlParams({
90 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
91 assertion,
92 }),
93 ),
94 )
95 .pipe(
96 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
97 Effect.mapError(
98 (cause) => new FcmClientError({ operation: "authorize", status: null, cause }),
99 ),
100 );
101 if (response.status !== 200)
102 return yield* new FcmClientError({ operation: "authorize", status: response.status });
103 return yield* response.json.pipe(
104 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
105 Effect.flatMap(decodeAccessToken),
106 Effect.map((body) => body.access_token),
107 Effect.mapError(
108 (cause) => new FcmClientError({ operation: "authorize", status: response.status, cause }),
109 ),
110 );
111 });
112 const [accessToken, invalidateToken] = yield* Effect.cachedInvalidateWithTTL(
113 authorize,
114 "50 minutes",
115 );
116
117 return FcmClient.of({
118 send: Effect.fn("relay.fcm.send")(function* (input) {
119 if (new TextEncoder().encode(encodeJson(input.data)).length > 4096)
120 return yield* new FcmClientError({ operation: "send", status: null });
121 if (Option.isNone(account))
122 return yield* new FcmClientError({ operation: "configuration", status: null });
123 const token = yield* accessToken.pipe(Effect.tapError(() => invalidateToken));
124 const response = yield* HttpClientRequest.post(
125 `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.value.project_id)}/messages:send`,
126 ).pipe(
127 HttpClientRequest.bearerToken(token),
128 HttpClientRequest.bodyJson({
129 message: {
130 token: input.token,
131 data: input.data,
132 android: {
133 priority: "HIGH",
134 ttl: "300s",
135 ...(!input.alert ? { collapse_key: "t3-agent-activity" } : {}),
136 ...(input.packageName ? { restricted_package_name: input.packageName } : {}),
137 },
138 },
139 }),
140 Effect.flatMap(client.execute),
141 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
142 Effect.mapError((cause) => new FcmClientError({ operation: "send", status: null, cause })),
143 );
144 if (response.status >= 200 && response.status < 300) return { unregistered: false };
145 if (response.status === 401) yield* invalidateToken;
146 const body = yield* response.json.pipe(
147 Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
148 Effect.mapError(
149 (cause) => new FcmClientError({ operation: "send", status: response.status, cause }),
150 ),
151 );
152 const decoded = decodeFcmError(body);
153 const unregistered =
154 Option.isSome(decoded) &&
155 decoded.value.error.details?.some(
156 (detail) =>
157 detail["@type"] === "type.googleapis.com/google.firebase.fcm.v1.FcmError" &&
158 detail.errorCode === "UNREGISTERED",
159 ) === true;
160 if (unregistered) return { unregistered: true };
161 return yield* new FcmClientError({ operation: "send", status: response.status });
162 }),
163 });
164});
165
166export const layer = Layer.effect(FcmClient, make);