1import * as socialmessaging from "@distilled.cloud/aws/socialmessaging";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as EffectStream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 type Tags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17export type RegistrationStatus = socialmessaging.RegistrationStatus;
18export type WhatsAppPhoneNumberSummary =
19 socialmessaging.WhatsAppPhoneNumberSummary;
20
21/**
22 * An event destination (Amazon SNS topic) that AWS End User Messaging Social
23 * publishes WhatsApp events (messages, message status updates) to.
24 */
25export interface LinkedWhatsAppBusinessAccountEventDestination {
26 /**
27 * ARN of the SNS topic that receives WhatsApp events for this account.
28 */
29 eventDestinationArn: string;
30 /**
31 * ARN of an IAM role the service assumes to publish to the topic. Omit to
32 * publish with a service principal.
33 */
34 roleArn?: string;
35}
36
37export interface LinkedWhatsAppBusinessAccountProps {
38 /**
39 * The unique identifier of the WhatsApp Business Account already linked to
40 * your AWS account (format `waba-...`).
41 *
42 * WhatsApp Business onboarding requires the Meta embedded-signup OAuth flow
43 * in the AWS console and cannot be automated — this resource adopts and
44 * manages an account that has already been linked. Changing the identifier
45 * replaces the resource.
46 */
47 accountId: string;
48 /**
49 * Event destinations (SNS topics) to publish WhatsApp events to. When set,
50 * the full list is synced (the API replaces the whole list). When omitted,
51 * existing event destinations are left untouched.
52 */
53 eventDestinations?: LinkedWhatsAppBusinessAccountEventDestination[];
54 /**
55 * Tags to associate with the linked WhatsApp Business Account.
56 */
57 tags?: Record<string, string>;
58}
59
60export interface LinkedWhatsAppBusinessAccount extends Resource<
61 "AWS.SocialMessaging.LinkedWhatsAppBusinessAccount",
62 LinkedWhatsAppBusinessAccountProps,
63 {
64 /**
65 * ARN of the linked WhatsApp Business Account.
66 */
67 arn: string;
68 /**
69 * The unique identifier of the linked WhatsApp Business Account
70 * (`waba-...`).
71 */
72 id: string;
73 /**
74 * The WhatsApp Business Account ID from Meta.
75 */
76 wabaId: string;
77 /**
78 * The name of the WhatsApp Business Account.
79 */
80 wabaName: string;
81 /**
82 * Registration status of the linked account (`COMPLETE` | `INCOMPLETE`).
83 */
84 registrationStatus: RegistrationStatus;
85 /**
86 * ISO-8601 date the WhatsApp Business Account was linked.
87 */
88 linkDate: string;
89 /**
90 * Event destinations currently configured on the account.
91 */
92 eventDestinations: LinkedWhatsAppBusinessAccountEventDestination[];
93 /**
94 * Phone numbers registered to the WhatsApp Business Account.
95 */
96 phoneNumbers: WhatsAppPhoneNumberSummary[];
97 /**
98 * Current tags reported for the account.
99 */
100 tags: Record<string, string>;
101 },
102 never,
103 Providers
104> {}
105
106/**
107 * A WhatsApp Business Account (WABA) linked to your AWS account through
108 * AWS End User Messaging Social.
109 *
110 * :::caution
111 * Linking a WhatsApp Business Account requires the Meta embedded-signup
112 * OAuth flow in the AWS console and cannot be automated. This resource
113 * adopts an already-linked account by its `waba-...` identifier and manages
114 * its event destinations and tags. Destroying the resource disassociates
115 * the WhatsApp Business Account from your AWS account.
116 * :::
117 * ### Managing a Linked Account
118 * **Example:** Adopt a console-linked WABA and route events to SNS
119 * ```typescript
120 * import * as AWS from "alchemy/AWS";
121 *
122 * const events = yield* AWS.SNS.Topic("WhatsAppEvents", {});
123 *
124 * const waba = yield* AWS.SocialMessaging.LinkedWhatsAppBusinessAccount(
125 * "Business",
126 * {
127 * // from the AWS End User Messaging Social console after onboarding
128 * accountId: "waba-0123456789abcdef0123456789abcdef",
129 * eventDestinations: [{ eventDestinationArn: events.topicArn }],
130 * tags: { team: "growth" },
131 * },
132 * );
133 * ```
134 *
135 * ### Consuming WhatsApp Events
136 * **Example:** Handle Inbound Messages in a Lambda
137 * WhatsApp events (inbound messages, message status updates) are delivered
138 * exclusively to the SNS topics listed in `eventDestinations` — there is no
139 * separate event source for this service. Compose the resource with
140 * `AWS.SNS.consumeTopicNotifications` on the destination topic:
141 * ```typescript
142 * const events = yield* AWS.SNS.Topic("WhatsAppEvents", {});
143 *
144 * const waba = yield* AWS.SocialMessaging.LinkedWhatsAppBusinessAccount(
145 * "Business",
146 * {
147 * accountId: "waba-0123456789abcdef0123456789abcdef",
148 * eventDestinations: [{ eventDestinationArn: events.topicArn }],
149 * },
150 * );
151 *
152 * // inside a Lambda Function's init effect
153 * yield* AWS.SNS.consumeTopicNotifications(events, (stream) =>
154 * stream.pipe(
155 * Stream.runForEach((notification) =>
156 * Effect.log("whatsapp event", notification.Message),
157 * ),
158 * ),
159 * );
160 * ```
161 *
162 * @resource
163 */
164export const LinkedWhatsAppBusinessAccount =
165 Resource<LinkedWhatsAppBusinessAccount>(
166 "AWS.SocialMessaging.LinkedWhatsAppBusinessAccount",
167 );
168
169/**
170 * The referenced WhatsApp Business Account is not linked to this AWS
171 * account. Linking requires the Meta embedded-signup flow in the AWS
172 * console and cannot be performed by the provider.
173 */
174export class WhatsAppBusinessAccountNotLinked extends Data.TaggedError(
175 "WhatsAppBusinessAccountNotLinked",
176)<{
177 readonly accountId: string;
178}> {}
179
180const fetchTags = Effect.fn(function* (arn: string) {
181 const response = yield* socialmessaging.listTagsForResource({
182 resourceArn: arn,
183 });
184 return Object.fromEntries(
185 (response.tags ?? []).flatMap((tag) =>
186 tag.value === undefined ? [] : [[tag.key, tag.value] as const],
187 ),
188 );
189});
190
191interface LinkedAccountState {
192 attrs: LinkedWhatsAppBusinessAccount["Attributes"];
193 account: socialmessaging.LinkedWhatsAppBusinessAccount;
194}
195
196const readLinkedAccount = Effect.fn(function* (accountId: string) {
197 const response = yield* socialmessaging
198 .getLinkedWhatsAppBusinessAccount({ id: accountId })
199 .pipe(
200 Effect.catchTag("ResourceNotFoundException", () =>
201 Effect.succeed(undefined),
202 ),
203 );
204 const account = response?.account;
205 if (account === undefined) return undefined;
206 const state: LinkedAccountState = {
207 account,
208 attrs: {
209 arn: account.arn,
210 id: account.id,
211 wabaId: account.wabaId,
212 wabaName: account.wabaName,
213 registrationStatus: account.registrationStatus,
214 linkDate: account.linkDate.toISOString(),
215 eventDestinations: account.eventDestinations.map((destination) => ({
216 eventDestinationArn: destination.eventDestinationArn,
217 roleArn: destination.roleArn,
218 })),
219 phoneNumbers: account.phoneNumbers.map((phone) => ({ ...phone })),
220 tags: yield* fetchTags(account.arn),
221 },
222 };
223 return state;
224});
225
226const sortDestinations = (
227 destinations: LinkedWhatsAppBusinessAccountEventDestination[],
228) =>
229 destinations
230 .map((destination) => ({
231 eventDestinationArn: destination.eventDestinationArn,
232 roleArn: destination.roleArn,
233 }))
234 .sort((l, r) => l.eventDestinationArn.localeCompare(r.eventDestinationArn));
235
236const destinationsDrifted = (
237 observed: LinkedWhatsAppBusinessAccountEventDestination[],
238 desired: LinkedWhatsAppBusinessAccountEventDestination[],
239) => {
240 const left = sortDestinations(observed);
241 const right = sortDestinations(desired);
242 return (
243 left.length !== right.length ||
244 left.some(
245 (destination, index) =>
246 destination.eventDestinationArn !== right[index].eventDestinationArn ||
247 destination.roleArn !== right[index].roleArn,
248 )
249 );
250};
251
252export const LinkedWhatsAppBusinessAccountProvider = () =>
253 Provider.effect(
254 LinkedWhatsAppBusinessAccount,
255 Effect.gen(function* () {
256 return {
257 stables: ["arn", "id", "wabaId"],
258 list: () =>
259 Effect.gen(function* () {
260 const summaries =
261 yield* socialmessaging.listLinkedWhatsAppBusinessAccounts
262 .pages({})
263 .pipe(
264 EffectStream.runCollect,
265 Effect.map((chunk) =>
266 Array.from(chunk).flatMap(
267 (page) => page.linkedAccounts ?? [],
268 ),
269 ),
270 );
271 const hydrated = yield* Effect.forEach(
272 summaries,
273 (summary) => readLinkedAccount(summary.id),
274 { concurrency: 5 },
275 );
276 return hydrated.flatMap((state) =>
277 state === undefined ? [] : [state.attrs],
278 );
279 }),
280 read: Effect.fn(function* ({ id, olds, output }) {
281 const accountId = output?.id ?? olds?.accountId;
282 if (accountId === undefined) return undefined;
283 const state = yield* readLinkedAccount(accountId);
284 if (state === undefined) return undefined;
285 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
286 ? state.attrs
287 : Unowned(state.attrs);
288 }),
289 diff: Effect.fn(function* ({ news, olds }) {
290 if (!isResolved(news)) return;
291 if (olds === undefined) return;
292 // The linked account identity is fixed by Meta onboarding.
293 if (news.accountId !== olds.accountId) {
294 return { action: "replace" } as const;
295 }
296 }),
297 reconcile: Effect.fn(function* ({ id, news, output, session }) {
298 if (!news?.accountId) {
299 return yield* Effect.fail(
300 new Error(
301 "LinkedWhatsAppBusinessAccount requires an accountId (link the WABA in the AWS console first)",
302 ),
303 );
304 }
305 const accountId = output?.id ?? news.accountId;
306 const internalTags = yield* createInternalTags(id);
307 const desiredTags = { ...internalTags, ...news.tags };
308
309 // Observe — onboarding is console-only, so the account must already
310 // be linked; there is no create path to fall through to.
311 const state = yield* readLinkedAccount(accountId);
312 if (state === undefined) {
313 return yield* Effect.fail(
314 new WhatsAppBusinessAccountNotLinked({ accountId }),
315 );
316 }
317
318 // Sync event destinations — the API replaces the full list.
319 if (
320 news.eventDestinations !== undefined &&
321 destinationsDrifted(
322 state.attrs.eventDestinations,
323 news.eventDestinations,
324 )
325 ) {
326 yield* socialmessaging.putWhatsAppBusinessAccountEventDestinations({
327 id: state.attrs.id,
328 eventDestinations: news.eventDestinations.map((destination) => ({
329 eventDestinationArn: destination.eventDestinationArn,
330 roleArn: destination.roleArn,
331 })),
332 });
333 yield* session.note(
334 `Updated event destinations for ${state.attrs.id}`,
335 );
336 }
337
338 // Sync tags — diff against observed cloud tags.
339 const { removed, upsert } = diffTags(state.attrs.tags, desiredTags);
340 if (removed.length > 0) {
341 yield* socialmessaging.untagResource({
342 resourceArn: state.attrs.arn,
343 tagKeys: removed,
344 });
345 }
346 if (upsert.length > 0) {
347 yield* socialmessaging.tagResource({
348 resourceArn: state.attrs.arn,
349 tags: upsert.map(({ Key, Value }) => ({
350 key: Key,
351 value: Value,
352 })),
353 });
354 }
355
356 yield* session.note(state.attrs.arn);
357
358 const final = yield* readLinkedAccount(accountId);
359 if (final === undefined) {
360 return yield* Effect.fail(
361 new WhatsAppBusinessAccountNotLinked({ accountId }),
362 );
363 }
364 return final.attrs;
365 }),
366 delete: Effect.fn(function* ({ output }) {
367 yield* socialmessaging
368 .disassociateWhatsAppBusinessAccount({ id: output.id })
369 .pipe(
370 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
371 );
372 }),
373 };
374 }),
375 );