1// @effect-diagnostics nodeBuiltinImport:off - This developer command reads local credential files.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as Effect from "effect/Effect";
5import * as Clock from "effect/Clock";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import * as Schema from "effect/Schema";
9import * as FetchHttpClient from "effect/http/FetchHttpClient";
10
11import * as RelayConfiguration from "../src/Config.ts";
12import * as WebCrypto from "../src/WebCrypto.ts";
13import * as FcmAssertionSigner from "../src/agentActivity/FcmAssertionSigner.ts";
14import * as FcmClient from "../src/agentActivity/FcmClient.ts";
15
16const Device = Schema.Struct({
17 token: Schema.NonEmptyString,
18 deviceId: Schema.NonEmptyString,
19 userId: Schema.NonEmptyString,
20 packageName: Schema.NonEmptyString,
21 deepLink: Schema.optional(Schema.NonEmptyString),
22});
23const decodeDevice = Schema.decodeUnknownEffect(Schema.fromJsonString(Device));
24const Phase = Schema.Literals(["running", "approval", "input", "completed", "failed", "end"]);
25const decodePhase = Schema.decodeUnknownEffect(Phase);
26
27class SmokeUsageError extends Schema.TaggedError<SmokeUsageError>()("SmokeUsageError", {}) {
28 override get message() {
29 return "Usage: node scripts/android-push-smoke.ts <service-account.json> <device.json> <running|approval|input|completed|failed|end>";
30 }
31}
32
33class SmokeCredentialReadError extends Schema.TaggedError<SmokeCredentialReadError>()(
34 "SmokeCredentialReadError",
35 { cause: Schema.Defect() },
36) {
37 override get message() {
38 return "Could not read service-account file.";
39 }
40}
41
42class SmokeDeviceReadError extends Schema.TaggedError<SmokeDeviceReadError>()(
43 "SmokeDeviceReadError",
44 { cause: Schema.Defect() },
45) {
46 override get message() {
47 return "Could not read device file.";
48 }
49}
50
51class SmokeUnregisteredDeviceError extends Schema.TaggedError<SmokeUnregisteredDeviceError>()(
52 "SmokeUnregisteredDeviceError",
53 {},
54) {
55 override get message() {
56 return "This device token is no longer registered with Firebase.";
57 }
58}
59
60const main = Effect.gen(function* () {
61 const [credentialPath, devicePath, phaseArg] = process.argv.slice(2);
62 if (!credentialPath || !devicePath || !phaseArg) return yield* new SmokeUsageError({});
63 const phase = yield* decodePhase(phaseArg);
64 const credentials = yield* Effect.tryPromise({
65 try: () => NodeFSP.readFile(credentialPath, "utf8"),
66 catch: (cause) => new SmokeCredentialReadError({ cause }),
67 });
68 const device = yield* Effect.tryPromise({
69 try: () => NodeFSP.readFile(devicePath, "utf8"),
70 catch: (cause) => new SmokeDeviceReadError({ cause }),
71 }).pipe(Effect.flatMap(decodeDevice));
72 const title =
73 phase === "completed"
74 ? "Agent finished"
75 : phase === "failed"
76 ? "Agent failed"
77 : phase === "approval"
78 ? "Approval needed"
79 : phase === "input"
80 ? "Input needed"
81 : null;
82 const active = phase === "running" || phase === "approval" || phase === "input";
83 const now = yield* Clock.currentTimeMillis;
84 const config: RelayConfiguration.RelayConfiguration["Service"] = {
85 relayIssuer: "http://localhost",
86 fcmServiceAccount: Redacted.make(credentials),
87 apns: null,
88 clerkSecretKey: Redacted.make(""),
89 clerkPublishableKey: "",
90 clerkJwtAudience: "",
91 apnsDeliveryJobSigningSecret: Redacted.make(""),
92 cloudMintPrivateKey: Redacted.make(""),
93 cloudMintPublicKey: "",
94 managedEndpointBaseDomain: undefined,
95 managedEndpointNamespace: undefined,
96 };
97 const result = yield* FcmClient.FcmClient.pipe(
98 Effect.flatMap((client) =>
99 client.send({
100 token: device.token,
101 packageName: device.packageName,
102 alert: title !== null,
103 data: {
104 t3_kind: "agent_activity",
105 device_id: device.deviceId,
106 user_id: device.userId,
107 updated_at: String(now),
108 active: String(active),
109 activity_title: active
110 ? "1 active agent"
111 : phase === "failed"
112 ? "Agent work failed"
113 : "Agent work completed",
114 activity_expires_at: String(
115 phase === "end" ? 0 : now + (active ? 2 * 60 * 60 * 1000 : 15 * 60 * 1000),
116 ),
117 activity_body: "Android notification test",
118 activity_path: device.deepLink ?? "/",
119 ...(title
120 ? {
121 alert_id: `smoke-${now}`,
122 alert_title: title,
123 alert_body: "T3 Code Android push test",
124 alert_path: device.deepLink ?? "/",
125 }
126 : {}),
127 },
128 }),
129 ),
130 Effect.provide(
131 FcmClient.layer.pipe(
132 Layer.provide(
133 FcmAssertionSigner.layer.pipe(
134 Layer.provide(Layer.succeed(WebCrypto.WebCrypto, { subtle: globalThis.crypto.subtle })),
135 ),
136 ),
137 Layer.provide(
138 Layer.mergeAll(
139 Layer.succeed(RelayConfiguration.RelayConfiguration, config),
140 FetchHttpClient.layer,
141 ),
142 ),
143 ),
144 ),
145 );
146 if (result.unregistered) return yield* new SmokeUnregisteredDeviceError({});
147 yield* Effect.logInfo(
148 `Firebase accepted the ${phase} notification. Verify delivery on the device.`,
149 );
150});
151
152NodeRuntime.runMain(main);