import * as Context from "effect/Context";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";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 * as HttpClient from "effect/unstable/http/HttpClient";import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as RelayConfiguration from "../Config.ts";import * as FcmAssertionSigner from "./FcmAssertionSigner.ts"; const FCM_HTTP_STAGE_TIMEOUT = "10 seconds"; const ServiceAccount = Schema.Struct({ project_id: Schema.NonEmptyString, client_email: Schema.NonEmptyString, private_key: Schema.NonEmptyString,});const decodeServiceAccount = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceAccount));const decodeAccessToken = Schema.decodeUnknownEffect( Schema.Struct({ access_token: Schema.NonEmptyString }),);const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));const decodeFcmError = Schema.decodeUnknownOption( Schema.Struct({ error: Schema.Struct({ status: Schema.optional(Schema.String), details: Schema.optional( Schema.Array( Schema.Struct({ "@type": Schema.optional(Schema.String), errorCode: Schema.optional(Schema.String), }), ), ), }), }),); export class FcmClientError extends Schema.TaggedError<FcmClientError>()("FcmClientError", { operation: Schema.Literals(["configuration", "authorize", "send"]), status: Schema.NullOr(Schema.Number), cause: Schema.optional(Schema.Defect()),}) { override get message() { return `FCM ${this.operation} failed${this.status === null ? "" : ` (${this.status})`}.`; }} export class FcmClient extends Context.Service< FcmClient, { readonly send: (input: { readonly token: string; readonly packageName: string | null; readonly data: Readonly<Record<string, string>>; readonly alert: boolean; }) => Effect.Effect<{ readonly unregistered: boolean }, FcmClientError>; }>()("t3code-relay/agentActivity/FcmClient") {} export const make = Effect.gen(function* () { const config = yield* RelayConfiguration.RelayConfiguration; const signer = yield* FcmAssertionSigner.FcmAssertionSigner; const client = yield* HttpClient.HttpClient; const account = config.fcmServiceAccount ? decodeServiceAccount(Redacted.value(config.fcmServiceAccount)) : Option.none(); const authorize = Effect.gen(function* () { if (Option.isNone(account)) return yield* new FcmClientError({ operation: "configuration", status: null }); const now = yield* DateTime.now; const assertion = yield* signer .sign({ privateKey: account.value.private_key, clientEmail: account.value.client_email, issuedAt: Math.floor(now.epochMilliseconds / 1000), }) .pipe( Effect.mapError( (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), ), ); const response = yield* client .execute( HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe( HttpClientRequest.bodyUrlParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion, }), ), ) .pipe( Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), Effect.mapError( (cause) => new FcmClientError({ operation: "authorize", status: null, cause }), ), ); if (response.status !== 200) return yield* new FcmClientError({ operation: "authorize", status: response.status }); return yield* response.json.pipe( Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), Effect.flatMap(decodeAccessToken), Effect.map((body) => body.access_token), Effect.mapError( (cause) => new FcmClientError({ operation: "authorize", status: response.status, cause }), ), ); }); const [accessToken, invalidateToken] = yield* Effect.cachedInvalidateWithTTL( authorize, "50 minutes", ); return FcmClient.of({ send: Effect.fn("relay.fcm.send")(function* (input) { if (new TextEncoder().encode(encodeJson(input.data)).length > 4096) return yield* new FcmClientError({ operation: "send", status: null }); if (Option.isNone(account)) return yield* new FcmClientError({ operation: "configuration", status: null }); const token = yield* accessToken.pipe(Effect.tapError(() => invalidateToken)); const response = yield* HttpClientRequest.post( `https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.value.project_id)}/messages:send`, ).pipe( HttpClientRequest.bearerToken(token), HttpClientRequest.bodyJson({ message: { token: input.token, data: input.data, android: { priority: "HIGH", ttl: "300s", ...(!input.alert ? { collapse_key: "t3-agent-activity" } : {}), ...(input.packageName ? { restricted_package_name: input.packageName } : {}), }, }, }), Effect.flatMap(client.execute), Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), Effect.mapError((cause) => new FcmClientError({ operation: "send", status: null, cause })), ); if (response.status >= 200 && response.status < 300) return { unregistered: false }; if (response.status === 401) yield* invalidateToken; const body = yield* response.json.pipe( Effect.timeout(FCM_HTTP_STAGE_TIMEOUT), Effect.mapError( (cause) => new FcmClientError({ operation: "send", status: response.status, cause }), ), ); const decoded = decodeFcmError(body); const unregistered = Option.isSome(decoded) && decoded.value.error.details?.some( (detail) => detail["@type"] === "type.googleapis.com/google.firebase.fcm.v1.FcmError" && detail.errorCode === "UNREGISTERED", ) === true; if (unregistered) return { unregistered: true }; return yield* new FcmClientError({ operation: "send", status: response.status }); }), });}); export const layer = Layer.effect(FcmClient, make);
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
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 * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
import * as RelayConfiguration from "../Config.ts";
import * as FcmAssertionSigner from "./FcmAssertionSigner.ts";
const FCM_HTTP_STAGE_TIMEOUT = "10 seconds";
const ServiceAccount = Schema.Struct({
project_id: Schema.NonEmptyString,
client_email: Schema.NonEmptyString,
private_key: Schema.NonEmptyString,
});
const decodeServiceAccount = Schema.decodeUnknownOption(Schema.fromJsonString(ServiceAccount));
const decodeAccessToken = Schema.decodeUnknownEffect(
Schema.Struct({ access_token: Schema.NonEmptyString }),
);
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const decodeFcmError = Schema.decodeUnknownOption(
Schema.Struct({
error: Schema.Struct({
status: Schema.optional(Schema.String),
details: Schema.optional(
Schema.Array(
Schema.Struct({
"@type": Schema.optional(Schema.String),
errorCode: Schema.optional(Schema.String),
}),
),
),
}),
}),
);
export class FcmClientError extends Schema.TaggedError<FcmClientError>()("FcmClientError", {
operation: Schema.Literals(["configuration", "authorize", "send"]),
status: Schema.NullOr(Schema.Number),
cause: Schema.optional(Schema.Defect()),
}) {
override get message() {
return `FCM ${this.operation} failed${this.status === null ? "" : ` (${this.status})`}.`;
}
}
export class FcmClient extends Context.Service<
FcmClient,
{
readonly send: (input: {
readonly token: string;
readonly packageName: string | null;
readonly data: Readonly<Record<string, string>>;
readonly alert: boolean;
}) => Effect.Effect<{ readonly unregistered: boolean }, FcmClientError>;
}
>()("t3code-relay/agentActivity/FcmClient") {}
export const make = Effect.gen(function* () {
const config = yield* RelayConfiguration.RelayConfiguration;
const signer = yield* FcmAssertionSigner.FcmAssertionSigner;
const client = yield* HttpClient.HttpClient;
const account = config.fcmServiceAccount
? decodeServiceAccount(Redacted.value(config.fcmServiceAccount))
: Option.none();
const authorize = Effect.gen(function* () {
if (Option.isNone(account))
return yield* new FcmClientError({ operation: "configuration", status: null });
const now = yield* DateTime.now;
const assertion = yield* signer
.sign({
privateKey: account.value.private_key,
clientEmail: account.value.client_email,
issuedAt: Math.floor(now.epochMilliseconds / 1000),
})
.pipe(
Effect.mapError(
(cause) => new FcmClientError({ operation: "authorize", status: null, cause }),
),
);
const response = yield* client
.execute(
HttpClientRequest.post("https://oauth2.googleapis.com/token").pipe(
HttpClientRequest.bodyUrlParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
}),
),
)
.pipe(
Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
Effect.mapError(
(cause) => new FcmClientError({ operation: "authorize", status: null, cause }),
),
);
if (response.status !== 200)
return yield* new FcmClientError({ operation: "authorize", status: response.status });
return yield* response.json.pipe(
Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
Effect.flatMap(decodeAccessToken),
Effect.map((body) => body.access_token),
Effect.mapError(
(cause) => new FcmClientError({ operation: "authorize", status: response.status, cause }),
),
);
});
const [accessToken, invalidateToken] = yield* Effect.cachedInvalidateWithTTL(
authorize,
"50 minutes",
);
return FcmClient.of({
send: Effect.fn("relay.fcm.send")(function* (input) {
if (new TextEncoder().encode(encodeJson(input.data)).length > 4096)
return yield* new FcmClientError({ operation: "send", status: null });
if (Option.isNone(account))
return yield* new FcmClientError({ operation: "configuration", status: null });
const token = yield* accessToken.pipe(Effect.tapError(() => invalidateToken));
const response = yield* HttpClientRequest.post(
`https://fcm.googleapis.com/v1/projects/${encodeURIComponent(account.value.project_id)}/messages:send`,
).pipe(
HttpClientRequest.bearerToken(token),
HttpClientRequest.bodyJson({
message: {
token: input.token,
data: input.data,
android: {
priority: "HIGH",
ttl: "300s",
...(!input.alert ? { collapse_key: "t3-agent-activity" } : {}),
...(input.packageName ? { restricted_package_name: input.packageName } : {}),
},
},
}),
Effect.flatMap(client.execute),
Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
Effect.mapError((cause) => new FcmClientError({ operation: "send", status: null, cause })),
);
if (response.status >= 200 && response.status < 300) return { unregistered: false };
if (response.status === 401) yield* invalidateToken;
const body = yield* response.json.pipe(
Effect.timeout(FCM_HTTP_STAGE_TIMEOUT),
Effect.mapError(
(cause) => new FcmClientError({ operation: "send", status: response.status, cause }),
),
);
const decoded = decodeFcmError(body);
const unregistered =
Option.isSome(decoded) &&
decoded.value.error.details?.some(
(detail) =>
detail["@type"] === "type.googleapis.com/google.firebase.fcm.v1.FcmError" &&
detail.errorCode === "UNREGISTERED",
) === true;
if (unregistered) return { unregistered: true };
return yield* new FcmClientError({ operation: "send", status: response.status });
}),
});
});
export const layer = Layer.effect(FcmClient, make);