@automate.ax/integration-contracts 0.160.0 → 0.163.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,6 +102,122 @@ export const META_ADS_LEAD_CREATED_EVENT_SCHEMA = z.object({
102
102
  pageId: META_ADS_ID_SCHEMA,
103
103
  })
104
104
 
105
+ const META_ADS_MESSAGE_ATTACHMENT_SCHEMA = z.object({
106
+ /** Provider attachment payload, such as `url`, `title`, or `stickerId`. */
107
+ payload: z.record(z.string(), encodableSchema).optional(),
108
+
109
+ /**
110
+ * Provider attachment type, such as `image`, `video`, `ig_reel`, or
111
+ * `story_mention`.
112
+ */
113
+ type: z.string(),
114
+ })
115
+
116
+ const META_ADS_MESSAGE_REFERRAL_SCHEMA = z.object({
117
+ adId: z.string().optional(),
118
+ adsContextData: z
119
+ .object({
120
+ adTitle: z.string().optional(),
121
+ flowId: z.string().optional(),
122
+ photoUrl: z.string().optional(),
123
+ postId: z.string().optional(),
124
+ productId: z.string().optional(),
125
+ videoUrl: z.string().optional(),
126
+ })
127
+ .optional(),
128
+ ref: z.string().optional(),
129
+ source: z.string().optional(),
130
+ type: z.string().optional(),
131
+ })
132
+
133
+ const META_ADS_RECEIVED_MESSAGE_BASE_SCHEMA = z.object({
134
+ attachments: META_ADS_MESSAGE_ATTACHMENT_SCHEMA.array(),
135
+ messageId: META_ADS_ID_SCHEMA,
136
+ quickReplyPayload: z.string().optional(),
137
+ recipientId: META_ADS_ID_SCHEMA,
138
+ referral: META_ADS_MESSAGE_REFERRAL_SCHEMA.optional(),
139
+ senderId: META_ADS_ID_SCHEMA,
140
+ text: z.string().optional(),
141
+ timestamp: z.number().int().nonnegative(),
142
+ })
143
+
144
+ export const META_ADS_PAGE_COMMENT_CREATED_EVENT_SCHEMA = z.object({
145
+ commentId: META_ADS_ID_SCHEMA,
146
+ createdTime: z.number().int().nonnegative(),
147
+ from: z.object({ id: z.string(), name: z.string().optional() }).optional(),
148
+ message: z.string().optional(),
149
+ pageId: META_ADS_ID_SCHEMA,
150
+ parentId: z.string().optional(),
151
+ photo: z.string().optional(),
152
+ post: z
153
+ .object({
154
+ id: z.string().optional(),
155
+ isPublished: z.boolean().optional(),
156
+ permalinkUrl: z.string().optional(),
157
+ promotionStatus: z.string().optional(),
158
+ statusType: z.string().optional(),
159
+ updatedTime: z.string().optional(),
160
+ })
161
+ .optional(),
162
+ postId: META_ADS_ID_SCHEMA,
163
+ video: z.string().optional(),
164
+ })
165
+
166
+ export const META_ADS_MESSENGER_MESSAGE_RECEIVED_EVENT_SCHEMA =
167
+ META_ADS_RECEIVED_MESSAGE_BASE_SCHEMA.extend({
168
+ pageId: META_ADS_ID_SCHEMA,
169
+ replyToMessageId: z.string().optional(),
170
+ })
171
+
172
+ export const META_ADS_INSTAGRAM_COMMENT_CREATED_EVENT_SCHEMA = z.object({
173
+ commentId: META_ADS_ID_SCHEMA,
174
+ from: z.object({ id: z.string(), username: z.string().optional() }),
175
+ instagramAccountId: META_ADS_ID_SCHEMA,
176
+ media: z.object({
177
+ adId: z.string().optional(),
178
+ adTitle: z.string().optional(),
179
+ id: META_ADS_ID_SCHEMA,
180
+ mediaProductType: z.string().optional(),
181
+ originalMediaId: z.string().optional(),
182
+ }),
183
+ occurredAt: z.number().int().nonnegative(),
184
+ parentId: z.string().optional(),
185
+ text: z.string().optional(),
186
+ })
187
+
188
+ export const META_ADS_INSTAGRAM_MENTION_CREATED_EVENT_SCHEMA = z.object({
189
+ commentId: z.string().optional(),
190
+ instagramAccountId: META_ADS_ID_SCHEMA,
191
+ kind: z.enum(["caption", "comment"]),
192
+ mediaId: META_ADS_ID_SCHEMA,
193
+ occurredAt: z.number().int().nonnegative(),
194
+ })
195
+
196
+ export const META_ADS_INSTAGRAM_MESSAGE_RECEIVED_EVENT_SCHEMA =
197
+ META_ADS_RECEIVED_MESSAGE_BASE_SCHEMA.extend({
198
+ instagramAccountId: META_ADS_ID_SCHEMA,
199
+ isUnsupported: z.boolean(),
200
+ replyTo: z
201
+ .object({
202
+ messageId: z.string().optional(),
203
+ story: z
204
+ .object({
205
+ id: z.string(),
206
+ linkStickerUrl: z.string().optional(),
207
+ url: z.string().optional(),
208
+ })
209
+ .optional(),
210
+ })
211
+ .optional(),
212
+ })
213
+
214
+ export const META_ADS_INSTAGRAM_MESSAGE_DELETED_EVENT_SCHEMA = z.object({
215
+ instagramAccountId: META_ADS_ID_SCHEMA,
216
+ messageId: META_ADS_ID_SCHEMA,
217
+ senderId: META_ADS_ID_SCHEMA,
218
+ timestamp: z.number().int().nonnegative(),
219
+ })
220
+
105
221
  export const META_ADS_TRIGGER_CONFIG_SCHEMA = z.object({
106
222
  account: z.string().optional(),
107
223
  adAccountId: META_ADS_ID_SCHEMA,
@@ -112,9 +228,37 @@ export const META_ADS_LEAD_TRIGGER_CONFIG_SCHEMA = z.object({
112
228
  pageId: META_ADS_ID_SCHEMA,
113
229
  })
114
230
 
231
+ export const META_ADS_PAGE_TRIGGER_CONFIG_SCHEMA = z.object({
232
+ account: z.string().optional(),
233
+ pageId: META_ADS_ID_SCHEMA,
234
+ })
235
+
236
+ export const META_ADS_INSTAGRAM_TRIGGER_CONFIG_SCHEMA = z.object({
237
+ account: z.string().optional(),
238
+ instagramAccountId: META_ADS_ID_SCHEMA,
239
+ })
240
+
115
241
  export interface NormalizedMetaAdsWebhook {
116
242
  adAccountEvents: z.output<typeof META_ADS_EVENT_SCHEMA>[]
243
+ instagramCommentEvents: z.output<
244
+ typeof META_ADS_INSTAGRAM_COMMENT_CREATED_EVENT_SCHEMA
245
+ >[]
246
+ instagramMentionEvents: z.output<
247
+ typeof META_ADS_INSTAGRAM_MENTION_CREATED_EVENT_SCHEMA
248
+ >[]
249
+ instagramMessageDeletedEvents: z.output<
250
+ typeof META_ADS_INSTAGRAM_MESSAGE_DELETED_EVENT_SCHEMA
251
+ >[]
252
+ instagramMessageEvents: z.output<
253
+ typeof META_ADS_INSTAGRAM_MESSAGE_RECEIVED_EVENT_SCHEMA
254
+ >[]
117
255
  leadEvents: z.output<typeof META_ADS_LEAD_CREATED_EVENT_SCHEMA>[]
256
+ messengerMessageEvents: z.output<
257
+ typeof META_ADS_MESSENGER_MESSAGE_RECEIVED_EVENT_SCHEMA
258
+ >[]
259
+ pageCommentEvents: z.output<
260
+ typeof META_ADS_PAGE_COMMENT_CREATED_EVENT_SCHEMA
261
+ >[]
118
262
  }
119
263
 
120
264
  export const metaAdsTriggerContracts = {
@@ -154,6 +298,22 @@ export const metaAdsTriggerContracts = {
154
298
  configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
155
299
  eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
156
300
  },
301
+ "meta-ads.instagramComment.created": {
302
+ configSchema: META_ADS_INSTAGRAM_TRIGGER_CONFIG_SCHEMA,
303
+ eventSchema: META_ADS_INSTAGRAM_COMMENT_CREATED_EVENT_SCHEMA,
304
+ },
305
+ "meta-ads.instagramMention.created": {
306
+ configSchema: META_ADS_INSTAGRAM_TRIGGER_CONFIG_SCHEMA,
307
+ eventSchema: META_ADS_INSTAGRAM_MENTION_CREATED_EVENT_SCHEMA,
308
+ },
309
+ "meta-ads.instagramMessage.deleted": {
310
+ configSchema: META_ADS_INSTAGRAM_TRIGGER_CONFIG_SCHEMA,
311
+ eventSchema: META_ADS_INSTAGRAM_MESSAGE_DELETED_EVENT_SCHEMA,
312
+ },
313
+ "meta-ads.instagramMessage.received": {
314
+ configSchema: META_ADS_INSTAGRAM_TRIGGER_CONFIG_SCHEMA,
315
+ eventSchema: META_ADS_INSTAGRAM_MESSAGE_RECEIVED_EVENT_SCHEMA,
316
+ },
157
317
  "meta-ads.insights.updated": {
158
318
  configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
159
319
  eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
@@ -162,6 +322,10 @@ export const metaAdsTriggerContracts = {
162
322
  configSchema: META_ADS_LEAD_TRIGGER_CONFIG_SCHEMA,
163
323
  eventSchema: META_ADS_LEAD_CREATED_EVENT_SCHEMA,
164
324
  },
325
+ "meta-ads.messengerMessage.received": {
326
+ configSchema: META_ADS_PAGE_TRIGGER_CONFIG_SCHEMA,
327
+ eventSchema: META_ADS_MESSENGER_MESSAGE_RECEIVED_EVENT_SCHEMA,
328
+ },
165
329
  "meta-ads.object.created": {
166
330
  configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
167
331
  eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
@@ -170,10 +334,94 @@ export const metaAdsTriggerContracts = {
170
334
  configSchema: META_ADS_TRIGGER_CONFIG_SCHEMA,
171
335
  eventSchema: META_ADS_SUBSCRIPTION_EVENT_SCHEMA,
172
336
  },
337
+ "meta-ads.pageComment.created": {
338
+ configSchema: META_ADS_PAGE_TRIGGER_CONFIG_SCHEMA,
339
+ eventSchema: META_ADS_PAGE_COMMENT_CREATED_EVENT_SCHEMA,
340
+ },
173
341
  } as const
174
342
 
343
+ const LEADGEN_VALUE_SCHEMA = z.object({
344
+ ad_id: z.string().optional(),
345
+ adgroup_id: z.string().optional(),
346
+ created_time: z.number().int().nonnegative(),
347
+ form_id: z.string(),
348
+ leadgen_id: z.string(),
349
+ page_id: z.string(),
350
+ })
351
+
352
+ const FEED_COMMENT_ADD_SCHEMA = z.object({
353
+ item: z.literal("comment"),
354
+ verb: z.literal("add"),
355
+ })
356
+
357
+ const FEED_COMMENT_VALUE_SCHEMA =
358
+ META_ADS_PAGE_COMMENT_CREATED_EVENT_SCHEMA.omit({ pageId: true }).extend({
359
+ createdTime: z.union([
360
+ META_ADS_PAGE_COMMENT_CREATED_EVENT_SCHEMA.shape.createdTime,
361
+ z
362
+ .string()
363
+ .transform((value) => Math.floor(Date.parse(value) / 1_000))
364
+ .pipe(META_ADS_PAGE_COMMENT_CREATED_EVENT_SCHEMA.shape.createdTime),
365
+ ]),
366
+ })
367
+
368
+ const INSTAGRAM_COMMENT_VALUE_SCHEMA = z.object({
369
+ commentId: z.string().optional(),
370
+ from: META_ADS_INSTAGRAM_COMMENT_CREATED_EVENT_SCHEMA.shape.from.extend({
371
+ selfIgScopedId: z.string().optional(),
372
+ }),
373
+ id: z.string().optional(),
374
+ media: META_ADS_INSTAGRAM_COMMENT_CREATED_EVENT_SCHEMA.shape.media,
375
+ parentId: z.string().optional(),
376
+ text: z.string().optional(),
377
+ })
378
+
379
+ const INSTAGRAM_MENTION_VALUE_SCHEMA = z.object({
380
+ commentId: z.string().optional(),
381
+ mediaId: META_ADS_ID_SCHEMA,
382
+ })
383
+
384
+ const MESSAGING_EVENT_SCHEMA = z.object({
385
+ message: z.object({
386
+ attachments: META_ADS_MESSAGE_ATTACHMENT_SCHEMA.extend({
387
+ payload: META_ADS_MESSAGE_ATTACHMENT_SCHEMA.shape.payload.nullable(),
388
+ })
389
+ .array()
390
+ .optional(),
391
+ isDeleted: z.boolean().optional(),
392
+ isEcho: z.boolean().optional(),
393
+ isUnsupported: z.boolean().optional(),
394
+ mid: META_ADS_ID_SCHEMA,
395
+ quickReply: z.object({ payload: z.string() }).optional(),
396
+ referral: META_ADS_MESSAGE_REFERRAL_SCHEMA.optional(),
397
+ replyTo: z
398
+ .object({
399
+ mid: z.string().optional(),
400
+ story: z
401
+ .object({
402
+ id: z.string(),
403
+ linkStickerUrl: z.string().optional(),
404
+ url: z.string().optional(),
405
+ })
406
+ .optional(),
407
+ })
408
+ .optional(),
409
+ text: z.string().optional(),
410
+ }),
411
+ recipient: z.object({ id: META_ADS_ID_SCHEMA }),
412
+ sender: z.object({ id: META_ADS_ID_SCHEMA }),
413
+ timestamp: z.number().int().nonnegative(),
414
+ })
415
+
175
416
  /**
176
- * Normalizes Meta's Page and ad-account envelopes into stable public events.
417
+ * Normalizes Meta's Page, Instagram, and ad-account envelopes into stable
418
+ * public events.
419
+ *
420
+ * Classification happens here: only new comments, inbound messages, and
421
+ * mentions become semantic events. The Page's own comments, Instagram's own
422
+ * comments and message echoes are dropped, and unsent Instagram messages become
423
+ * deletion events. Malformed comment, mention, and message items are dropped so
424
+ * they can't reject a delivery that also carries leads.
177
425
  *
178
426
  * @param input - Untrusted provider webhook envelope.
179
427
  */
@@ -181,38 +429,231 @@ export function normalizeMetaAdsWebhook(
181
429
  input: unknown,
182
430
  ): NormalizedMetaAdsWebhook {
183
431
  const envelope = META_ADS_WEBHOOK_ENVELOPE_SCHEMA.parse(input)
184
- const adAccountEvents: z.output<typeof META_ADS_EVENT_SCHEMA>[] = []
185
- const leadEvents: z.output<typeof META_ADS_LEAD_CREATED_EVENT_SCHEMA>[] = []
186
- for (const entry of envelope.entry) {
187
- for (const change of entry.changes) {
188
- if (envelope.object === "page" && change.field === "leadgen") {
189
- const value = z
190
- .object({
191
- ad_id: z.string().optional(),
192
- adgroup_id: z.string().optional(),
193
- created_time: z.number().int().nonnegative(),
194
- form_id: z.string(),
195
- leadgen_id: z.string(),
196
- page_id: z.string(),
197
- })
198
- .parse(change.value)
199
- leadEvents.push({
200
- adId: value.ad_id,
201
- adgroupId: value.adgroup_id,
202
- createdTime: value.created_time,
203
- formId: value.form_id,
204
- leadgenId: value.leadgen_id,
205
- pageId: value.page_id,
206
- })
207
- } else if (envelope.object === "ad_account") {
208
- adAccountEvents.push({
209
- accountId: entry.id,
210
- field: change.field,
211
- occurredAt: entry.time,
212
- value: encodableSchema.parse(fromMeta(change.value)),
213
- })
214
- }
215
- }
432
+ const entries = envelope.entry.map((entry) => ({
433
+ ...entry,
434
+ changes: entry.changes ?? [],
435
+ messaging: entry.messaging ?? [],
436
+ }))
437
+ const pageEntries = envelope.object === "page" ? entries : []
438
+ const instagramEntries = envelope.object === "instagram" ? entries : []
439
+ return {
440
+ adAccountEvents:
441
+ envelope.object === "ad_account"
442
+ ? entries.flatMap((entry) =>
443
+ entry.changes.map((change) => ({
444
+ accountId: entry.id,
445
+ field: change.field,
446
+ occurredAt: entry.time,
447
+ value: encodableSchema.parse(fromMeta(change.value)),
448
+ })),
449
+ )
450
+ : [],
451
+ instagramCommentEvents: instagramEntries.flatMap((entry) =>
452
+ entry.changes
453
+ .filter(({ field }) => field === "comments")
454
+ .flatMap(({ value }) => parseInstagramComment(entry, value) ?? []),
455
+ ),
456
+ instagramMentionEvents: instagramEntries.flatMap((entry) =>
457
+ entry.changes
458
+ .filter(({ field }) => field === "mentions")
459
+ .flatMap(({ value }) => parseInstagramMention(entry, value) ?? []),
460
+ ),
461
+ instagramMessageDeletedEvents: instagramEntries.flatMap((entry) =>
462
+ entry.messaging.flatMap((event) => {
463
+ const message = parseReceivedMessage(event)
464
+ if (!message?.isDeleted || message.isEcho) return []
465
+ return [
466
+ {
467
+ instagramAccountId: message.common.recipientId,
468
+ messageId: message.common.messageId,
469
+ senderId: message.common.senderId,
470
+ timestamp: message.common.timestamp,
471
+ },
472
+ ]
473
+ }),
474
+ ),
475
+ instagramMessageEvents: instagramEntries.flatMap((entry) =>
476
+ entry.messaging.flatMap((event) => {
477
+ const message = parseReceivedMessage(event)
478
+ if (!message || message.isEcho || message.isDeleted) return []
479
+ return [
480
+ {
481
+ ...message.common,
482
+ instagramAccountId: message.common.recipientId,
483
+ isUnsupported: message.isUnsupported,
484
+ ...(message.replyTo && {
485
+ replyTo: {
486
+ messageId: message.replyTo.mid,
487
+ story: message.replyTo.story,
488
+ },
489
+ }),
490
+ },
491
+ ]
492
+ }),
493
+ ),
494
+ leadEvents: pageEntries.flatMap((entry) =>
495
+ entry.changes
496
+ .filter(({ field }) => field === "leadgen")
497
+ .map(({ value }) => {
498
+ const lead = LEADGEN_VALUE_SCHEMA.parse(value)
499
+ return {
500
+ adId: lead.ad_id,
501
+ adgroupId: lead.adgroup_id,
502
+ createdTime: lead.created_time,
503
+ formId: lead.form_id,
504
+ leadgenId: lead.leadgen_id,
505
+ pageId: lead.page_id,
506
+ }
507
+ }),
508
+ ),
509
+ messengerMessageEvents: pageEntries.flatMap((entry) =>
510
+ entry.messaging.flatMap((event) => {
511
+ const message = parseReceivedMessage(event)
512
+ if (!message || message.isEcho) return []
513
+ return [
514
+ {
515
+ ...message.common,
516
+ pageId: entry.id,
517
+ replyToMessageId: message.replyTo?.mid,
518
+ },
519
+ ]
520
+ }),
521
+ ),
522
+ pageCommentEvents: pageEntries.flatMap((entry) =>
523
+ entry.changes.flatMap(({ field, value }) => {
524
+ if (
525
+ field !== "feed" ||
526
+ !FEED_COMMENT_ADD_SCHEMA.safeParse(value).success
527
+ )
528
+ return []
529
+ const comment = parsePageComment(entry.id, value)
530
+ return !comment || comment.from?.id === entry.id ? [] : [comment]
531
+ }),
532
+ ),
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Normalizes one Page `feed` comment-add change, or returns `undefined` when
538
+ * the item is malformed so one bad item can't reject a whole delivery.
539
+ *
540
+ * Meta documents `created_time` only as a datetime, so both Unix seconds and
541
+ * ISO strings are accepted. Top-level comments may carry the post ID as
542
+ * `parent_id`; only a distinct parent identifies a reply.
543
+ *
544
+ * @param pageId - Page that owns the feed.
545
+ * @param input - Raw feed change value.
546
+ */
547
+ function parsePageComment(
548
+ pageId: string,
549
+ input: unknown,
550
+ ): z.output<typeof META_ADS_PAGE_COMMENT_CREATED_EVENT_SCHEMA> | undefined {
551
+ const parsed = FEED_COMMENT_VALUE_SCHEMA.safeParse(fromMeta(input))
552
+ if (!parsed.success) return undefined
553
+ const { parentId, ...value } = parsed.data
554
+ return {
555
+ ...value,
556
+ pageId,
557
+ ...(parentId && parentId !== value.postId && { parentId }),
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Normalizes one Instagram `comments` change, or returns `undefined` when the
563
+ * item is malformed or the account wrote it.
564
+ *
565
+ * Meta's reference schema names the comment `id`, while its Facebook Login
566
+ * samples name it `comment_id`; both are accepted. The account's own comments
567
+ * match either the entry ID or the commenter's `self_ig_scoped_id`; dropping
568
+ * them keeps reply automations from triggering themselves.
569
+ *
570
+ * @param entry - Instagram webhook entry.
571
+ * @param entry.id - Instagram professional account ID.
572
+ * @param entry.time - Delivery time in Unix seconds.
573
+ * @param input - Raw comments change value.
574
+ */
575
+ function parseInstagramComment(
576
+ entry: { id: string; time: number },
577
+ input: unknown,
578
+ ):
579
+ | z.output<typeof META_ADS_INSTAGRAM_COMMENT_CREATED_EVENT_SCHEMA>
580
+ | undefined {
581
+ const parsed = INSTAGRAM_COMMENT_VALUE_SCHEMA.safeParse(fromMeta(input))
582
+ if (!parsed.success) return undefined
583
+ const {
584
+ commentId,
585
+ from: { selfIgScopedId, ...from },
586
+ id,
587
+ ...value
588
+ } = parsed.data
589
+ const resolvedId = id ?? commentId
590
+ if (!resolvedId || from.id === entry.id || from.id === selfIgScopedId) {
591
+ return undefined
592
+ }
593
+ return {
594
+ ...value,
595
+ commentId: resolvedId,
596
+ from,
597
+ instagramAccountId: entry.id,
598
+ occurredAt: entry.time,
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Normalizes one Instagram `mentions` change into a caption or comment mention,
604
+ * or returns `undefined` when the item is malformed.
605
+ *
606
+ * @param entry - Instagram webhook entry.
607
+ * @param entry.id - Instagram professional account ID.
608
+ * @param entry.time - Delivery time in Unix seconds.
609
+ * @param input - Raw mentions change value.
610
+ */
611
+ function parseInstagramMention(
612
+ entry: { id: string; time: number },
613
+ input: unknown,
614
+ ):
615
+ | z.output<typeof META_ADS_INSTAGRAM_MENTION_CREATED_EVENT_SCHEMA>
616
+ | undefined {
617
+ const parsed = INSTAGRAM_MENTION_VALUE_SCHEMA.safeParse(fromMeta(input))
618
+ if (!parsed.success) return undefined
619
+ return {
620
+ ...parsed.data,
621
+ instagramAccountId: entry.id,
622
+ kind: parsed.data.commentId ? "comment" : "caption",
623
+ occurredAt: entry.time,
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Parses one Messenger or Instagram `messaging` event that carries a message.
629
+ *
630
+ * Returns `undefined` for other event kinds, such as reads, reactions,
631
+ * postbacks, referrals, and handoff `admin_text` notices without a message ID,
632
+ * and for malformed messages.
633
+ *
634
+ * @param input - Raw messaging event.
635
+ */
636
+ function parseReceivedMessage(input: unknown) {
637
+ const parsed = MESSAGING_EVENT_SCHEMA.safeParse(fromMeta(input))
638
+ if (!parsed.success) return undefined
639
+ const { message, recipient, sender, timestamp } = parsed.data
640
+ return {
641
+ common: {
642
+ attachments: (message.attachments ?? []).map(({ payload, type }) => ({
643
+ type,
644
+ ...(payload && { payload }),
645
+ })),
646
+ messageId: message.mid,
647
+ quickReplyPayload: message.quickReply?.payload,
648
+ recipientId: recipient.id,
649
+ referral: message.referral,
650
+ senderId: sender.id,
651
+ text: message.text,
652
+ timestamp,
653
+ },
654
+ isDeleted: message.isDeleted === true,
655
+ isEcho: message.isEcho === true,
656
+ isUnsupported: message.isUnsupported === true,
657
+ replyTo: message.replyTo,
216
658
  }
217
- return { adAccountEvents, leadEvents }
218
659
  }
@@ -1,3 +1,4 @@
1
1
  export * from "./api"
2
2
  export * from "./events"
3
+ export * from "./pages"
3
4
  export * from "./schemas"
@@ -0,0 +1,98 @@
1
+ import * as z from "zod"
2
+ import { getMetaAdsApi, type MetaAdsResolvedAccount } from "./api"
3
+
4
+ const PAGE_TOKEN_SCHEMA = z.object({ accessToken: z.string().min(1) })
5
+ const LINKED_PAGE_SCHEMA = z
6
+ .object({
7
+ accessToken: z.string().min(1),
8
+ id: z.string(),
9
+ instagramBusinessAccount: z.object({ id: z.string() }).optional(),
10
+ })
11
+ .array()
12
+
13
+ /**
14
+ * Creates a Graph client authenticated as one Page.
15
+ *
16
+ * Page comment, messaging, conversation, and `subscribed_apps` endpoints need a
17
+ * Page access token. The token is derived from the connected user token on each
18
+ * call and never persisted.
19
+ *
20
+ * @param account - Resolved Meta Ads OAuth account.
21
+ * @param pageId - Facebook Page the connected identity manages.
22
+ */
23
+ export async function getMetaAdsPageApi(
24
+ account: MetaAdsResolvedAccount,
25
+ pageId: string,
26
+ ) {
27
+ const { accessToken } = await getMetaAdsApi(account).call(pageId, {
28
+ query: { fields: ["access_token"] },
29
+ responseSchema: PAGE_TOKEN_SCHEMA,
30
+ })
31
+ return pageClient(accessToken)
32
+ }
33
+
34
+ /**
35
+ * Finds the managed Facebook Page linked to one Instagram professional account
36
+ * and creates a Graph client authenticated as that Page.
37
+ *
38
+ * Instagram messaging with Facebook Login sends through the linked Page, and
39
+ * Instagram webhooks are enabled by installing the app on that Page.
40
+ *
41
+ * @param account - Resolved Meta Ads OAuth account.
42
+ * @param instagramAccountId - Instagram professional account ID.
43
+ * @throws When no managed Page links to the Instagram account.
44
+ */
45
+ export async function getMetaAdsInstagramPageApi(
46
+ account: MetaAdsResolvedAccount,
47
+ instagramAccountId: string,
48
+ ) {
49
+ const page = await findLinkedPage(account, instagramAccountId)
50
+ if (!page) {
51
+ throw new Error(
52
+ `Instagram account ${instagramAccountId} is not linked to a Facebook Page this Meta Ads account manages.`,
53
+ )
54
+ }
55
+ return { api: pageClient(page.accessToken), pageId: page.id }
56
+ }
57
+
58
+ /**
59
+ * Walks the connected identity's Pages until one links to the Instagram
60
+ * account.
61
+ *
62
+ * @param account - Resolved Meta Ads OAuth account.
63
+ * @param instagramAccountId - Instagram professional account ID.
64
+ * @param after - Cursor for the next `me/accounts` page.
65
+ */
66
+ async function findLinkedPage(
67
+ account: MetaAdsResolvedAccount,
68
+ instagramAccountId: string,
69
+ after?: string,
70
+ ): Promise<z.output<typeof LINKED_PAGE_SCHEMA>[number] | undefined> {
71
+ const { items, pageInfo } = await getMetaAdsApi(account).page("me/accounts", {
72
+ query: {
73
+ after,
74
+ fields: ["id", "access_token", "instagram_business_account"],
75
+ limit: 100,
76
+ },
77
+ responseSchema: LINKED_PAGE_SCHEMA,
78
+ })
79
+ const page = items.find(
80
+ ({ instagramBusinessAccount }) =>
81
+ instagramBusinessAccount?.id === instagramAccountId,
82
+ )
83
+ if (page || !pageInfo?.next || !pageInfo.after) return page
84
+ return findLinkedPage(account, instagramAccountId, pageInfo.after)
85
+ }
86
+
87
+ /**
88
+ * Wraps one Page access token in the shared Graph client.
89
+ *
90
+ * @param accessToken - Page access token.
91
+ */
92
+ function pageClient(accessToken: string) {
93
+ return getMetaAdsApi({
94
+ connectionMethodId: "oauth",
95
+ secret: { accessToken, expiresAt: null, tokenType: "bearer" },
96
+ serviceId: "meta-ads",
97
+ })
98
+ }