@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.
- package/.env.example +76 -0
- package/README.md +423 -0
- package/api/social/connect/callback/route.ts +669 -0
- package/api/social/connect/route.ts +327 -0
- package/api/social/disconnect/route.ts +187 -0
- package/api/social/publish/route.ts +402 -0
- package/docs/01-getting-started/01-introduction.md +471 -0
- package/docs/01-getting-started/02-installation.md +471 -0
- package/docs/01-getting-started/03-configuration.md +515 -0
- package/docs/02-core-features/01-oauth-integration.md +501 -0
- package/docs/02-core-features/02-publishing.md +527 -0
- package/docs/02-core-features/03-token-management.md +661 -0
- package/docs/02-core-features/04-audit-logging.md +646 -0
- package/docs/03-advanced-usage/01-provider-apis.md +764 -0
- package/docs/03-advanced-usage/02-custom-integrations.md +695 -0
- package/docs/03-advanced-usage/03-per-client-architecture.md +575 -0
- package/docs/04-use-cases/01-agency-management.md +661 -0
- package/docs/04-use-cases/02-content-publishing.md +668 -0
- package/docs/04-use-cases/03-analytics-reporting.md +748 -0
- package/entities/audit-logs/audit-logs.config.ts +150 -0
- package/lib/oauth-helper.ts +167 -0
- package/lib/providers/facebook.ts +672 -0
- package/lib/providers/index.ts +21 -0
- package/lib/providers/instagram.ts +791 -0
- package/lib/validation.ts +155 -0
- package/migrations/001_social_media_tables.sql +167 -0
- package/package.json +15 -0
- package/plugin.config.ts +81 -0
- package/tsconfig.json +47 -0
- package/types/social.types.ts +171 -0
|
@@ -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
|
+
}
|