1import * as Lambda from "@/AWS/Lambda";
2import * as SocialMessaging from "@/AWS/SocialMessaging";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import path from "pathe";
8
9const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
10
11// Resolved at deploy time (the test process exports it before deploying).
12// At Lambda runtime the module re-evaluates with the fallback, which is
13// fine — the bound resource's attributes resolve from deployed state, not
14// from these props.
15const WABA_ID = process.env.AWS_TEST_SOCIALMESSAGING_WABA_ID ?? "waba-unset";
16
17// Well-formed but nonexistent identifiers for typed-error probe routes.
18const BOGUS_PHONE_ID = "phone-number-id-0123456789abcdef0123456789abcdef";
19const BOGUS_MEDIA_ID = "alchemy-nonexistent-media-id";
20
21export class SocialMessagingBindingsFunction extends Lambda.Function<Lambda.Function>()(
22 "SocialMessagingBindingsFunction",
23) {}
24
25export default SocialMessagingBindingsFunction.make(
26 {
27 main,
28 functionUrl: true,
29 },
30 Effect.gen(function* () {
31 // Adopts the console-linked WABA (see LinkedWhatsAppBusinessAccount
32 // docs) — stack.destroy() disassociates it, requiring console re-signup.
33 const account = yield* SocialMessaging.LinkedWhatsAppBusinessAccount(
34 "BindingsWaba",
35 { accountId: WABA_ID },
36 );
37
38 const sendMessage = yield* SocialMessaging.SendWhatsAppMessage(account);
39 const postMedia = yield* SocialMessaging.PostWhatsAppMessageMedia(account);
40 const getMedia = yield* SocialMessaging.GetWhatsAppMessageMedia(account);
41 const deleteMedia =
42 yield* SocialMessaging.DeleteWhatsAppMessageMedia(account);
43 const getPhoneNumber =
44 yield* SocialMessaging.GetLinkedWhatsAppBusinessAccountPhoneNumber(
45 account,
46 );
47 const createTemplate =
48 yield* SocialMessaging.CreateWhatsAppMessageTemplate(account);
49 const createFromLibrary =
50 yield* SocialMessaging.CreateWhatsAppMessageTemplateFromLibrary(account);
51 const uploadTemplateMedia =
52 yield* SocialMessaging.CreateWhatsAppMessageTemplateMedia(account);
53 const getTemplate =
54 yield* SocialMessaging.GetWhatsAppMessageTemplate(account);
55 const listTemplates =
56 yield* SocialMessaging.ListWhatsAppMessageTemplates(account);
57 const browseLibrary =
58 yield* SocialMessaging.ListWhatsAppTemplateLibrary(account);
59 const updateTemplate =
60 yield* SocialMessaging.UpdateWhatsAppMessageTemplate(account);
61 const deleteTemplate =
62 yield* SocialMessaging.DeleteWhatsAppMessageTemplate(account);
63 const createFlow = yield* SocialMessaging.CreateWhatsAppFlow(account);
64 const getFlow = yield* SocialMessaging.GetWhatsAppFlow(account);
65 const getFlowPreview =
66 yield* SocialMessaging.GetWhatsAppFlowPreview(account);
67 const listFlows = yield* SocialMessaging.ListWhatsAppFlows(account);
68 const listFlowAssets =
69 yield* SocialMessaging.ListWhatsAppFlowAssets(account);
70 const updateFlow = yield* SocialMessaging.UpdateWhatsAppFlow(account);
71 const updateFlowAssets =
72 yield* SocialMessaging.UpdateWhatsAppFlowAssets(account);
73 const publishFlow = yield* SocialMessaging.PublishWhatsAppFlow(account);
74 const deprecateFlow = yield* SocialMessaging.DeprecateWhatsAppFlow(account);
75 const deleteFlow = yield* SocialMessaging.DeleteWhatsAppFlow(account);
76
77 const bound = {
78 sendMessage,
79 postMedia,
80 getMedia,
81 deleteMedia,
82 getPhoneNumber,
83 createTemplate,
84 createFromLibrary,
85 uploadTemplateMedia,
86 getTemplate,
87 listTemplates,
88 browseLibrary,
89 updateTemplate,
90 deleteTemplate,
91 createFlow,
92 getFlow,
93 getFlowPreview,
94 listFlows,
95 listFlowAssets,
96 updateFlow,
97 updateFlowAssets,
98 publishFlow,
99 deprecateFlow,
100 deleteFlow,
101 };
102
103 return {
104 fetch: Effect.gen(function* () {
105 const request = yield* HttpServerRequest;
106 const url = new URL(request.originalUrl);
107 const pathname = url.pathname;
108
109 if (request.method === "GET" && pathname === "/bindings") {
110 return yield* HttpServerResponse.json({
111 bound: Object.keys(bound),
112 });
113 }
114
115 // Read-only round-trips against the linked account — prove the
116 // WABA-scoped grant + id injection end-to-end.
117 if (request.method === "GET" && pathname === "/templates") {
118 const response = yield* listTemplates({ maxResults: 25 });
119 return yield* HttpServerResponse.json({
120 count: (response.templates ?? []).length,
121 });
122 }
123
124 if (request.method === "GET" && pathname === "/flows") {
125 const response = yield* listFlows({ maxResults: 25 });
126 return yield* HttpServerResponse.json({
127 count: (response.flows ?? []).length,
128 });
129 }
130
131 if (request.method === "GET" && pathname === "/library") {
132 const response = yield* browseLibrary({ maxResults: 5 });
133 return yield* HttpServerResponse.json({
134 count: (response.metaLibraryTemplates ?? []).length,
135 });
136 }
137
138 // Typed-error probes: a bogus identifier round-trips the typed tag,
139 // proving the grant reached the API (an IAM gap would surface
140 // AccessDeniedException instead).
141 if (request.method === "GET" && pathname === "/phone/typed-not-found") {
142 const typed = yield* getPhoneNumber({ id: BOGUS_PHONE_ID }).pipe(
143 Effect.map(() => false),
144 Effect.catchTag(
145 ["ResourceNotFoundException", "InvalidParametersException"],
146 () => Effect.succeed(true),
147 ),
148 );
149 return yield* HttpServerResponse.json({ typed });
150 }
151
152 if (
153 request.method === "GET" &&
154 pathname === "/template/typed-not-found"
155 ) {
156 const typed = yield* getTemplate({ metaTemplateId: "0" }).pipe(
157 Effect.map(() => false),
158 Effect.catchTag(
159 [
160 "ResourceNotFoundException",
161 "InvalidParametersException",
162 "AccessDeniedByMetaException",
163 "DependencyException",
164 "ValidationException",
165 ],
166 () => Effect.succeed(true),
167 ),
168 );
169 return yield* HttpServerResponse.json({ typed });
170 }
171
172 if (request.method === "GET" && pathname === "/flow/typed-not-found") {
173 const typed = yield* getFlow({ flowId: "0" }).pipe(
174 Effect.map(() => false),
175 Effect.catchTag(
176 [
177 "ResourceNotFoundException",
178 "InvalidParametersException",
179 "AccessDeniedByMetaException",
180 "DependencyException",
181 "ValidationException",
182 ],
183 () => Effect.succeed(true),
184 ),
185 );
186 return yield* HttpServerResponse.json({ typed });
187 }
188
189 if (
190 request.method === "POST" &&
191 pathname === "/media/typed-not-found"
192 ) {
193 const get = yield* getMedia({
194 mediaId: BOGUS_MEDIA_ID,
195 originationPhoneNumberId: BOGUS_PHONE_ID,
196 metadataOnly: true,
197 }).pipe(
198 Effect.map(() => false),
199 Effect.catchTag(
200 [
201 "ResourceNotFoundException",
202 "InvalidParametersException",
203 "AccessDeniedByMetaException",
204 "DependencyException",
205 "ValidationException",
206 ],
207 () => Effect.succeed(true),
208 ),
209 );
210 const del = yield* deleteMedia({
211 mediaId: BOGUS_MEDIA_ID,
212 originationPhoneNumberId: BOGUS_PHONE_ID,
213 }).pipe(
214 Effect.map(() => false),
215 Effect.catchTag(
216 [
217 "ResourceNotFoundException",
218 "InvalidParametersException",
219 "AccessDeniedByMetaException",
220 "DependencyException",
221 "ValidationException",
222 ],
223 () => Effect.succeed(true),
224 ),
225 );
226 return yield* HttpServerResponse.json({ get, del });
227 }
228
229 if (request.method === "POST" && pathname === "/send/typed-invalid") {
230 // A bogus phone-number id is rejected with a typed error before
231 // any message is sent — proves the SendWhatsAppMessage grant
232 // without messaging a real user.
233 const typed = yield* sendMessage({
234 originationPhoneNumberId: BOGUS_PHONE_ID,
235 metaApiVersion: "v20.0",
236 message: new TextEncoder().encode(
237 JSON.stringify({
238 messaging_product: "whatsapp",
239 to: "+10000000000",
240 type: "text",
241 text: { body: "alchemy-socialmessaging-probe" },
242 }),
243 ),
244 }).pipe(
245 Effect.map(() => false),
246 Effect.catchTag(
247 [
248 "ResourceNotFoundException",
249 "InvalidParametersException",
250 "AccessDeniedByMetaException",
251 "DependencyException",
252 "ValidationException",
253 ],
254 () => Effect.succeed(true),
255 ),
256 );
257 return yield* HttpServerResponse.json({ typed });
258 }
259
260 return yield* HttpServerResponse.json(
261 { error: "Not found", method: request.method, pathname },
262 { status: 404 },
263 );
264 }).pipe(Effect.orDie),
265 };
266 }).pipe(
267 Effect.provide(
268 Layer.mergeAll(
269 SocialMessaging.SendWhatsAppMessageHttp,
270 SocialMessaging.PostWhatsAppMessageMediaHttp,
271 SocialMessaging.GetWhatsAppMessageMediaHttp,
272 SocialMessaging.DeleteWhatsAppMessageMediaHttp,
273 SocialMessaging.GetLinkedWhatsAppBusinessAccountPhoneNumberHttp,
274 SocialMessaging.CreateWhatsAppMessageTemplateHttp,
275 SocialMessaging.CreateWhatsAppMessageTemplateFromLibraryHttp,
276 SocialMessaging.CreateWhatsAppMessageTemplateMediaHttp,
277 SocialMessaging.GetWhatsAppMessageTemplateHttp,
278 SocialMessaging.ListWhatsAppMessageTemplatesHttp,
279 SocialMessaging.ListWhatsAppTemplateLibraryHttp,
280 SocialMessaging.UpdateWhatsAppMessageTemplateHttp,
281 SocialMessaging.DeleteWhatsAppMessageTemplateHttp,
282 SocialMessaging.CreateWhatsAppFlowHttp,
283 SocialMessaging.GetWhatsAppFlowHttp,
284 SocialMessaging.GetWhatsAppFlowPreviewHttp,
285 SocialMessaging.ListWhatsAppFlowsHttp,
286 SocialMessaging.ListWhatsAppFlowAssetsHttp,
287 SocialMessaging.UpdateWhatsAppFlowHttp,
288 SocialMessaging.UpdateWhatsAppFlowAssetsHttp,
289 SocialMessaging.PublishWhatsAppFlowHttp,
290 SocialMessaging.DeprecateWhatsAppFlowHttp,
291 SocialMessaging.DeleteWhatsAppFlowHttp,
292 ),
293 ),
294 ),
295);