@nextsparkjs/plugin-social-media-publisher 0.1.0-beta.1

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.
@@ -0,0 +1,791 @@
1
+ /**
2
+ * Instagram Business API Wrapper
3
+ *
4
+ * Provides methods for publishing to Instagram Business Accounts via Facebook Graph API
5
+ * Uses Facebook Page tokens to publish to Instagram Business Accounts
6
+ *
7
+ * @see https://developers.facebook.com/docs/instagram-api
8
+ * @see https://developers.facebook.com/docs/instagram-platform
9
+ */
10
+
11
+ const GRAPH_API_VERSION = 'v21.0'
12
+ // Instagram Graph API via Facebook - when using Page tokens, we must use Facebook Graph API
13
+ // The Instagram Business Account ID works with both endpoints, but Page tokens only work with graph.facebook.com
14
+ const GRAPH_API_BASE = `https://graph.facebook.com/${GRAPH_API_VERSION}`
15
+
16
+ // Legacy: Direct Instagram Platform API (requires Instagram Platform API tokens, not Page tokens)
17
+ const INSTAGRAM_DIRECT_API_BASE = `https://graph.instagram.com/${GRAPH_API_VERSION}`
18
+
19
+ export interface InstagramPublishOptions {
20
+ igAccountId: string
21
+ accessToken: string
22
+ imageUrl?: string
23
+ videoUrl?: string
24
+ caption?: string
25
+ isCarousel?: boolean
26
+ carouselItems?: string[] // Array of media URLs
27
+ }
28
+
29
+ interface InstagramCarouselItem {
30
+ imageUrl: string
31
+ containerId?: string
32
+ status: 'pending' | 'processing' | 'ready' | 'error'
33
+ error?: string
34
+ }
35
+
36
+ export interface InstagramPublishResult {
37
+ success: boolean
38
+ postId?: string
39
+ postUrl?: string
40
+ error?: string
41
+ errorDetails?: unknown
42
+ }
43
+
44
+ export interface InstagramAccountInfo {
45
+ id: string
46
+ username: string
47
+ accountType?: string // 'BUSINESS' or 'CREATOR'
48
+ profilePictureUrl?: string
49
+ followersCount?: number
50
+ followsCount?: number
51
+ mediaCount?: number
52
+ }
53
+
54
+ export interface InstagramInsights {
55
+ impressions: number
56
+ reach: number
57
+ engagement: number
58
+ likes: number
59
+ comments: number
60
+ saves: number
61
+ profileViews: number
62
+ }
63
+
64
+ export class InstagramAPI {
65
+ /**
66
+ * Publish a photo to Instagram Business Account
67
+ *
68
+ * Instagram publishing is a 2-step process:
69
+ * 1. Create media container
70
+ * 2. Publish the container
71
+ */
72
+ static async publishPhoto(options: InstagramPublishOptions): Promise<InstagramPublishResult> {
73
+ if (!options.imageUrl) {
74
+ return {
75
+ success: false,
76
+ error: 'Image URL is required for photo posts',
77
+ }
78
+ }
79
+
80
+ try {
81
+ // Step 1: Create media container
82
+ const containerResponse = await fetch(
83
+ `${GRAPH_API_BASE}/${options.igAccountId}/media`,
84
+ {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ },
89
+ body: JSON.stringify({
90
+ image_url: options.imageUrl,
91
+ caption: options.caption || '',
92
+ access_token: options.accessToken,
93
+ }),
94
+ }
95
+ )
96
+
97
+ const containerData = await containerResponse.json()
98
+
99
+ if (containerData.error) {
100
+ console.error('[InstagramAPI] Container creation failed:', containerData.error.message)
101
+ return {
102
+ success: false,
103
+ error: containerData.error.message,
104
+ errorDetails: containerData.error,
105
+ }
106
+ }
107
+
108
+ const creationId = containerData.id
109
+
110
+ // Wait for Instagram to process the image (recommended: 2-5 seconds)
111
+ await new Promise(resolve => setTimeout(resolve, 2000))
112
+
113
+ // Step 2: Publish the container
114
+ const publishResponse = await fetch(
115
+ `${GRAPH_API_BASE}/${options.igAccountId}/media_publish`,
116
+ {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ },
121
+ body: JSON.stringify({
122
+ creation_id: creationId,
123
+ access_token: options.accessToken,
124
+ }),
125
+ }
126
+ )
127
+
128
+ const publishData = await publishResponse.json()
129
+
130
+ if (publishData.error) {
131
+ console.error('[InstagramAPI] Publishing failed:', publishData.error.message)
132
+ return {
133
+ success: false,
134
+ error: publishData.error.message,
135
+ errorDetails: publishData.error,
136
+ }
137
+ }
138
+
139
+ return {
140
+ success: true,
141
+ postId: publishData.id,
142
+ postUrl: `https://www.instagram.com/p/${this.getShortcodeFromId(publishData.id)}`,
143
+ }
144
+ } catch (error) {
145
+ console.error('[InstagramAPI] Exception during publish:', error)
146
+ return {
147
+ success: false,
148
+ error: error instanceof Error ? error.message : 'Unknown error',
149
+ errorDetails: error,
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Publish a video to Instagram Business Account
156
+ */
157
+ static async publishVideo(options: InstagramPublishOptions): Promise<InstagramPublishResult> {
158
+ if (!options.videoUrl) {
159
+ return {
160
+ success: false,
161
+ error: 'Video URL is required for video posts',
162
+ }
163
+ }
164
+
165
+ try {
166
+ // Step 1: Create video container
167
+ const containerResponse = await fetch(
168
+ `${GRAPH_API_BASE}/${options.igAccountId}/media`,
169
+ {
170
+ method: 'POST',
171
+ headers: {
172
+ 'Content-Type': 'application/json',
173
+ },
174
+ body: JSON.stringify({
175
+ media_type: 'VIDEO',
176
+ video_url: options.videoUrl,
177
+ caption: options.caption || '',
178
+ access_token: options.accessToken,
179
+ }),
180
+ }
181
+ )
182
+
183
+ const containerData = await containerResponse.json()
184
+
185
+ if (containerData.error) {
186
+ return {
187
+ success: false,
188
+ error: containerData.error.message,
189
+ errorDetails: containerData.error,
190
+ }
191
+ }
192
+
193
+ const creationId = containerData.id
194
+
195
+ // Poll for video processing status (videos take longer than images)
196
+ const isReady = await this.waitForVideoProcessing(
197
+ creationId,
198
+ options.accessToken
199
+ )
200
+
201
+ if (!isReady) {
202
+ return {
203
+ success: false,
204
+ error: 'Video processing timed out',
205
+ }
206
+ }
207
+
208
+ // Step 2: Publish the container
209
+ const publishResponse = await fetch(
210
+ `${GRAPH_API_BASE}/${options.igAccountId}/media_publish`,
211
+ {
212
+ method: 'POST',
213
+ headers: {
214
+ 'Content-Type': 'application/json',
215
+ },
216
+ body: JSON.stringify({
217
+ creation_id: creationId,
218
+ access_token: options.accessToken,
219
+ }),
220
+ }
221
+ )
222
+
223
+ const publishData = await publishResponse.json()
224
+
225
+ if (publishData.error) {
226
+ return {
227
+ success: false,
228
+ error: publishData.error.message,
229
+ errorDetails: publishData.error,
230
+ }
231
+ }
232
+
233
+ return {
234
+ success: true,
235
+ postId: publishData.id,
236
+ postUrl: `https://www.instagram.com/p/${this.getShortcodeFromId(publishData.id)}`,
237
+ }
238
+ } catch (error) {
239
+ return {
240
+ success: false,
241
+ error: error instanceof Error ? error.message : 'Unknown error',
242
+ errorDetails: error,
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Get Instagram Business Account info
249
+ */
250
+ static async getAccountInfo(
251
+ igAccountId: string,
252
+ accessToken: string
253
+ ): Promise<InstagramAccountInfo> {
254
+ try {
255
+ const response = await fetch(
256
+ `${GRAPH_API_BASE}/${igAccountId}?` +
257
+ `fields=id,username,profile_picture_url,followers_count,follows_count,media_count&` +
258
+ `access_token=${accessToken}`
259
+ )
260
+
261
+ const data = await response.json()
262
+
263
+ if (data.error) {
264
+ throw new Error(data.error.message)
265
+ }
266
+
267
+ return {
268
+ id: data.id,
269
+ username: data.username,
270
+ profilePictureUrl: data.profile_picture_url,
271
+ followersCount: data.followers_count,
272
+ followsCount: data.follows_count,
273
+ mediaCount: data.media_count,
274
+ }
275
+ } catch (error) {
276
+ throw new Error(
277
+ `Failed to fetch Instagram account info: ${error instanceof Error ? error.message : 'Unknown error'}`
278
+ )
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Get Instagram account insights (analytics)
284
+ */
285
+ static async getAccountInsights(
286
+ igAccountId: string,
287
+ accessToken: string
288
+ ): Promise<InstagramInsights> {
289
+ try {
290
+ const response = await fetch(
291
+ `${GRAPH_API_BASE}/${igAccountId}/insights?` +
292
+ `metric=impressions,reach,profile_views&` +
293
+ `period=day&` +
294
+ `access_token=${accessToken}`
295
+ )
296
+
297
+ const data = await response.json()
298
+
299
+ if (data.error) {
300
+ throw new Error(data.error.message)
301
+ }
302
+
303
+ const insights: InstagramInsights = {
304
+ impressions: 0,
305
+ reach: 0,
306
+ engagement: 0,
307
+ likes: 0,
308
+ comments: 0,
309
+ saves: 0,
310
+ profileViews: 0,
311
+ }
312
+
313
+ data.data?.forEach((metric: any) => {
314
+ const value = metric.values?.[0]?.value || 0
315
+ if (metric.name === 'impressions') {
316
+ insights.impressions = value
317
+ } else if (metric.name === 'reach') {
318
+ insights.reach = value
319
+ } else if (metric.name === 'profile_views') {
320
+ insights.profileViews = value
321
+ }
322
+ })
323
+
324
+ return insights
325
+ } catch (error) {
326
+ throw new Error(
327
+ `Failed to fetch Instagram insights: ${error instanceof Error ? error.message : 'Unknown error'}`
328
+ )
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Get Instagram media insights (post analytics)
334
+ */
335
+ static async getMediaInsights(
336
+ mediaId: string,
337
+ accessToken: string
338
+ ): Promise<Partial<InstagramInsights>> {
339
+ try {
340
+ const response = await fetch(
341
+ `${GRAPH_API_BASE}/${mediaId}/insights?` +
342
+ `metric=engagement,impressions,reach,saved&` +
343
+ `access_token=${accessToken}`
344
+ )
345
+
346
+ const data = await response.json()
347
+
348
+ if (data.error) {
349
+ throw new Error(data.error.message)
350
+ }
351
+
352
+ const insights: Partial<InstagramInsights> = {}
353
+
354
+ data.data?.forEach((metric: any) => {
355
+ const value = metric.values?.[0]?.value || 0
356
+ if (metric.name === 'engagement') {
357
+ insights.engagement = value
358
+ } else if (metric.name === 'impressions') {
359
+ insights.impressions = value
360
+ } else if (metric.name === 'reach') {
361
+ insights.reach = value
362
+ } else if (metric.name === 'saved') {
363
+ insights.saves = value
364
+ }
365
+ })
366
+
367
+ return insights
368
+ } catch (error) {
369
+ throw new Error(
370
+ `Failed to fetch media insights: ${error instanceof Error ? error.message : 'Unknown error'}`
371
+ )
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Get Instagram Business Account from Facebook Page (Legacy - for backward compatibility)
377
+ *
378
+ * @deprecated Use getAccountInfoFromToken for new Instagram Platform API (Direct Login)
379
+ */
380
+ static async getInstagramAccountFromPage(
381
+ pageId: string,
382
+ pageAccessToken: string
383
+ ): Promise<{ id: string; username: string } | null> {
384
+ try {
385
+ console.log(`[InstagramAPI] Checking Page ${pageId} for Instagram Business account...`)
386
+
387
+ const response = await fetch(
388
+ `${GRAPH_API_BASE}/${pageId}?fields=instagram_business_account&access_token=${pageAccessToken}`
389
+ )
390
+
391
+ const data = await response.json()
392
+
393
+ console.log(`[InstagramAPI] Page ${pageId} API response:`, JSON.stringify(data, null, 2))
394
+
395
+ if (data.error) {
396
+ console.error(`[InstagramAPI] ❌ API Error for Page ${pageId}:`, data.error)
397
+ console.error(` Error Code: ${data.error.code}`)
398
+ console.error(` Error Type: ${data.error.type}`)
399
+ console.error(` Error Message: ${data.error.message}`)
400
+ if (data.error.error_subcode) {
401
+ console.error(` Error Subcode: ${data.error.error_subcode}`)
402
+ }
403
+ return null
404
+ }
405
+
406
+ if (!data.instagram_business_account) {
407
+ console.log(`[InstagramAPI] ℹ️ Page ${pageId} has no instagram_business_account field (not linked)`)
408
+ return null
409
+ }
410
+
411
+ console.log(`[InstagramAPI] ✅ Found Instagram Business Account ID: ${data.instagram_business_account.id}`)
412
+
413
+ // Get username
414
+ const accountResponse = await fetch(
415
+ `${GRAPH_API_BASE}/${data.instagram_business_account.id}?fields=username&access_token=${pageAccessToken}`
416
+ )
417
+
418
+ const accountData = await accountResponse.json()
419
+
420
+ if (accountData.error) {
421
+ console.error(`[InstagramAPI] ❌ Failed to fetch username for IG account ${data.instagram_business_account.id}:`, accountData.error)
422
+ return null
423
+ }
424
+
425
+ console.log(`[InstagramAPI] ✅ Username: @${accountData.username}`)
426
+
427
+ return {
428
+ id: data.instagram_business_account.id,
429
+ username: accountData.username,
430
+ }
431
+ } catch (error) {
432
+ console.error(`[InstagramAPI] ❌ Exception while checking Page ${pageId}:`, error)
433
+ if (error instanceof Error) {
434
+ console.error(` Exception message: ${error.message}`)
435
+ console.error(` Stack trace:`, error.stack)
436
+ }
437
+ return null
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Get Instagram account info from access token (Instagram Platform API - Direct Login)
443
+ *
444
+ * Uses the new Instagram Platform API that doesn't require Facebook Page connection
445
+ * Works with Instagram Business and Creator accounts
446
+ *
447
+ * NOTE: This requires Instagram Platform API tokens (from direct Instagram OAuth),
448
+ * NOT Facebook Page tokens. Use getInstagramAccountFromPage for Page token scenarios.
449
+ *
450
+ * @param accessToken - Long-lived Instagram Platform API access token
451
+ * @returns Instagram account information
452
+ */
453
+ static async getAccountInfoFromToken(
454
+ accessToken: string
455
+ ): Promise<InstagramAccountInfo> {
456
+ try {
457
+ console.log('[InstagramAPI] Fetching account info using Instagram Platform API (Direct Login)...')
458
+
459
+ const response = await fetch(
460
+ `${INSTAGRAM_DIRECT_API_BASE}/me?` +
461
+ `fields=id,username,account_type,media_count,profile_picture_url,followers_count,follows_count&` +
462
+ `access_token=${accessToken}`
463
+ )
464
+
465
+ const data = await response.json()
466
+
467
+ if (data.error) {
468
+ console.error('[InstagramAPI] ❌ API Error:', data.error)
469
+ throw new Error(data.error.message || 'Failed to fetch Instagram account info')
470
+ }
471
+
472
+ console.log('[InstagramAPI] ✅ Account info retrieved:', {
473
+ id: data.id,
474
+ username: data.username,
475
+ accountType: data.account_type,
476
+ followersCount: data.followers_count
477
+ })
478
+
479
+ return {
480
+ id: data.id,
481
+ username: data.username,
482
+ accountType: data.account_type, // 'BUSINESS' or 'CREATOR'
483
+ mediaCount: data.media_count,
484
+ profilePictureUrl: data.profile_picture_url,
485
+ followersCount: data.followers_count,
486
+ followsCount: data.follows_count,
487
+ }
488
+ } catch (error) {
489
+ console.error('[InstagramAPI] ❌ Exception while fetching account info:', error)
490
+ throw new Error(
491
+ `Failed to fetch Instagram account info: ${error instanceof Error ? error.message : 'Unknown error'}`
492
+ )
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Wait for video processing to complete
498
+ * Polls status every 2 seconds, max 30 seconds
499
+ */
500
+ private static async waitForVideoProcessing(
501
+ creationId: string,
502
+ accessToken: string,
503
+ maxAttempts: number = 15
504
+ ): Promise<boolean> {
505
+ for (let i = 0; i < maxAttempts; i++) {
506
+ await new Promise(resolve => setTimeout(resolve, 2000))
507
+
508
+ const response = await fetch(
509
+ `${GRAPH_API_BASE}/${creationId}?fields=status_code&access_token=${accessToken}`
510
+ )
511
+
512
+ const data = await response.json()
513
+
514
+ if (data.status_code === 'FINISHED') {
515
+ return true
516
+ }
517
+
518
+ if (data.status_code === 'ERROR') {
519
+ return false
520
+ }
521
+
522
+ // Continue polling if status is IN_PROGRESS
523
+ }
524
+
525
+ return false
526
+ }
527
+
528
+ /**
529
+ * Convert Instagram media ID to shortcode for URL
530
+ * Instagram uses base64-like encoding for shortcodes
531
+ */
532
+ private static getShortcodeFromId(id: string): string {
533
+ // This is a simplified version
534
+ // In production, you'd use Instagram's actual conversion algorithm
535
+ // For now, we'll just return the ID (Instagram API should provide permalink)
536
+ return id
537
+ }
538
+
539
+ /**
540
+ * Publish a carousel (multiple images) to Instagram Business Account
541
+ *
542
+ * Instagram carousel publishing is a 4-step process:
543
+ * 1. Create container for each image with is_carousel_item=true
544
+ * 2. Wait for all containers to finish processing
545
+ * 3. Create carousel container with media_type=CAROUSEL
546
+ * 4. Publish the carousel
547
+ */
548
+ static async publishCarousel(options: InstagramPublishOptions): Promise<InstagramPublishResult> {
549
+ const { igAccountId, accessToken, carouselItems, caption } = options
550
+
551
+ // Validation
552
+ if (!carouselItems || carouselItems.length < 2) {
553
+ return {
554
+ success: false,
555
+ error: 'Carousel requires at least 2 images',
556
+ }
557
+ }
558
+
559
+ if (carouselItems.length > 10) {
560
+ return {
561
+ success: false,
562
+ error: 'Instagram allows maximum 10 images per carousel',
563
+ }
564
+ }
565
+
566
+ try {
567
+ console.log(`[InstagramAPI] Creating carousel with ${carouselItems.length} images...`)
568
+
569
+ // Step 1: Create containers for each image
570
+ const containerIds: string[] = []
571
+ const errors: { index: number; error: string }[] = []
572
+
573
+ for (let i = 0; i < carouselItems.length; i++) {
574
+ const imageUrl = carouselItems[i]
575
+ console.log(`[InstagramAPI] Creating container for image ${i + 1}/${carouselItems.length}...`)
576
+
577
+ const containerResult = await this.createCarouselItemContainer(
578
+ igAccountId,
579
+ accessToken,
580
+ imageUrl
581
+ )
582
+
583
+ if (containerResult.success && containerResult.containerId) {
584
+ containerIds.push(containerResult.containerId)
585
+ console.log(`[InstagramAPI] ✅ Container created for image ${i + 1}: ${containerResult.containerId}`)
586
+ } else {
587
+ console.error(`[InstagramAPI] ❌ Failed to create container for image ${i + 1}: ${containerResult.error}`)
588
+ errors.push({ index: i + 1, error: containerResult.error || 'Unknown error' })
589
+ }
590
+ }
591
+
592
+ if (errors.length > 0) {
593
+ const errorMessage = `Failed to create containers for images: ${errors.map(e => `#${e.index}: ${e.error}`).join(', ')}`
594
+ console.error(`[InstagramAPI] ${errorMessage}`)
595
+ return {
596
+ success: false,
597
+ error: errorMessage,
598
+ errorDetails: errors,
599
+ }
600
+ }
601
+
602
+ console.log(`[InstagramAPI] All ${containerIds.length} containers created, waiting for processing...`)
603
+
604
+ // Step 2: Wait for all containers to be ready
605
+ const readyContainers = await this.waitForCarouselContainers(containerIds, accessToken)
606
+
607
+ if (!readyContainers.allReady) {
608
+ const errorMessage = 'Some carousel images failed processing'
609
+ console.error(`[InstagramAPI] ${errorMessage}`)
610
+ console.error('[InstagramAPI] Container statuses:', readyContainers.statuses)
611
+ return {
612
+ success: false,
613
+ error: errorMessage,
614
+ errorDetails: readyContainers.statuses,
615
+ }
616
+ }
617
+
618
+ console.log('[InstagramAPI] ✅ All containers ready')
619
+
620
+ // ⚠️ CRITICAL WORKAROUND: Instagram API Race Condition (Error 2207027)
621
+ // Even after status_code='FINISHED', Instagram needs 5-10 seconds to make containers
622
+ // available for carousel creation. Without this delay, carousel creation fails with:
623
+ // "Media ID is not available" (error_subcode: 2207027)
624
+ // This is a known Instagram API bug where status_code doesn't reflect actual availability.
625
+ console.log('[InstagramAPI] ⏳ Waiting 8 seconds for Instagram to sync containers (API race condition workaround)...')
626
+ await new Promise(resolve => setTimeout(resolve, 8000))
627
+
628
+ // Step 3: Create carousel container
629
+ console.log('[InstagramAPI] Creating carousel container...')
630
+ const carouselContainerResponse = await fetch(
631
+ `${GRAPH_API_BASE}/${igAccountId}/media`,
632
+ {
633
+ method: 'POST',
634
+ headers: { 'Content-Type': 'application/json' },
635
+ body: JSON.stringify({
636
+ media_type: 'CAROUSEL',
637
+ caption: caption || '',
638
+ children: containerIds,
639
+ access_token: accessToken,
640
+ }),
641
+ }
642
+ )
643
+
644
+ const carouselContainerData = await carouselContainerResponse.json()
645
+
646
+ if (carouselContainerData.error) {
647
+ console.error('[InstagramAPI] Carousel container creation failed:', carouselContainerData.error.message)
648
+ return {
649
+ success: false,
650
+ error: carouselContainerData.error.message,
651
+ errorDetails: carouselContainerData.error,
652
+ }
653
+ }
654
+
655
+ const carouselCreationId = carouselContainerData.id
656
+ console.log(`[InstagramAPI] ✅ Carousel container created: ${carouselCreationId}`)
657
+
658
+ // Step 4: Publish carousel
659
+ console.log('[InstagramAPI] Publishing carousel...')
660
+ const publishResponse = await fetch(
661
+ `${GRAPH_API_BASE}/${igAccountId}/media_publish`,
662
+ {
663
+ method: 'POST',
664
+ headers: { 'Content-Type': 'application/json' },
665
+ body: JSON.stringify({
666
+ creation_id: carouselCreationId,
667
+ access_token: accessToken,
668
+ }),
669
+ }
670
+ )
671
+
672
+ const publishData = await publishResponse.json()
673
+
674
+ if (publishData.error) {
675
+ console.error('[InstagramAPI] Carousel publishing failed:', publishData.error.message)
676
+ return {
677
+ success: false,
678
+ error: publishData.error.message,
679
+ errorDetails: publishData.error,
680
+ }
681
+ }
682
+
683
+ console.log(`[InstagramAPI] ✅ Carousel published successfully: ${publishData.id}`)
684
+
685
+ return {
686
+ success: true,
687
+ postId: publishData.id,
688
+ postUrl: `https://www.instagram.com/p/${this.getShortcodeFromId(publishData.id)}`,
689
+ }
690
+ } catch (error) {
691
+ console.error('[InstagramAPI] Exception during carousel publish:', error)
692
+ return {
693
+ success: false,
694
+ error: error instanceof Error ? error.message : 'Unknown error',
695
+ errorDetails: error,
696
+ }
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Create a carousel item container
702
+ * @private
703
+ */
704
+ private static async createCarouselItemContainer(
705
+ igAccountId: string,
706
+ accessToken: string,
707
+ imageUrl: string
708
+ ): Promise<{ success: boolean; containerId?: string; error?: string }> {
709
+ try {
710
+ const response = await fetch(
711
+ `${GRAPH_API_BASE}/${igAccountId}/media`,
712
+ {
713
+ method: 'POST',
714
+ headers: { 'Content-Type': 'application/json' },
715
+ body: JSON.stringify({
716
+ image_url: imageUrl,
717
+ is_carousel_item: true,
718
+ access_token: accessToken,
719
+ }),
720
+ }
721
+ )
722
+
723
+ const data = await response.json()
724
+
725
+ if (data.error) {
726
+ return { success: false, error: data.error.message }
727
+ }
728
+
729
+ return { success: true, containerId: data.id }
730
+ } catch (error) {
731
+ return {
732
+ success: false,
733
+ error: error instanceof Error ? error.message : 'Unknown error',
734
+ }
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Wait for carousel containers to finish processing
740
+ * Polls status every 2 seconds, max 60 seconds
741
+ * @private
742
+ */
743
+ private static async waitForCarouselContainers(
744
+ containerIds: string[],
745
+ accessToken: string,
746
+ maxWaitMs: number = 60000 // 60 seconds max
747
+ ): Promise<{ allReady: boolean; statuses: Record<string, string> }> {
748
+ const startTime = Date.now()
749
+ const statuses: Record<string, string> = {}
750
+
751
+ while (Date.now() - startTime < maxWaitMs) {
752
+ let allReady = true
753
+
754
+ for (const containerId of containerIds) {
755
+ try {
756
+ const response = await fetch(
757
+ `${GRAPH_API_BASE}/${containerId}?fields=status_code&access_token=${accessToken}`
758
+ )
759
+ const data = await response.json()
760
+
761
+ statuses[containerId] = data.status_code || 'UNKNOWN'
762
+
763
+ if (data.status_code === 'ERROR') {
764
+ console.error(`[InstagramAPI] Container ${containerId} failed with ERROR status`)
765
+ return { allReady: false, statuses }
766
+ }
767
+
768
+ if (data.status_code !== 'FINISHED') {
769
+ allReady = false
770
+ }
771
+ } catch (error) {
772
+ console.error(`[InstagramAPI] Error checking container ${containerId}:`, error)
773
+ statuses[containerId] = 'ERROR'
774
+ return { allReady: false, statuses }
775
+ }
776
+ }
777
+
778
+ if (allReady) {
779
+ console.log('[InstagramAPI] All containers FINISHED')
780
+ return { allReady: true, statuses }
781
+ }
782
+
783
+ // Wait 2 seconds before checking again
784
+ console.log('[InstagramAPI] Containers still processing, waiting 2 seconds...')
785
+ await new Promise(resolve => setTimeout(resolve, 2000))
786
+ }
787
+
788
+ console.error('[InstagramAPI] Timeout waiting for containers to process')
789
+ return { allReady: false, statuses }
790
+ }
791
+ }