Skip to content

Commit ecf1d7d

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
refactor Instagram integration architecture (#6143)
Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent 19b0312 commit ecf1d7d

12 files changed

Lines changed: 297 additions & 8 deletions

File tree

apps/sim/app/api/auth/oauth2/callback/instagram/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
1717
import { isSameOrigin } from '@/lib/core/utils/validation'
1818
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1919
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
20-
import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants'
2120
import {
2221
parseInstagramLongLivedToken,
2322
parseInstagramProfile,
2423
parseInstagramShortLivedToken,
2524
} from '@/lib/oauth/instagram'
2625
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
2726
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
27+
import { INSTAGRAM_GRAPH_BASE } from '@/tools/instagram/constants'
2828

2929
const logger = createLogger('InstagramCallback')
3030

apps/sim/app/api/tools/instagram/publish-carousel/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { instagramPublishCarouselContract } from '@/lib/api/contracts/tools/inst
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { resolveInstagramCarouselMedia } from '@/lib/integrations/instagram/resolve-media'
8+
import { resolveInstagramCarouselMedia } from '@/app/api/tools/instagram/resolve-media'
99
import {
1010
createMediaContainer,
1111
publishMediaContainer,

apps/sim/app/api/tools/instagram/publish-image/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { instagramPublishImageContract } from '@/lib/api/contracts/tools/instagr
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
8+
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
99
import {
1010
createMediaContainer,
1111
publishMediaContainer,

apps/sim/app/api/tools/instagram/publish-reel/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { instagramPublishReelContract } from '@/lib/api/contracts/tools/instagra
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
8+
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
99
import {
1010
createMediaContainer,
1111
publishMediaContainer,

apps/sim/app/api/tools/instagram/publish-story/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { instagramPublishStoryContract } from '@/lib/api/contracts/tools/instagr
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
8+
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
99
import {
1010
createMediaContainer,
1111
publishMediaContainer,

apps/sim/app/api/tools/instagram/publish-video/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { instagramPublishVideoContract } from '@/lib/api/contracts/tools/instagr
55
import { parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
8+
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
99
import {
1010
createMediaContainer,
1111
publishMediaContainer,
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { Logger } from '@sim/logger'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockHasCloudStorage, mockResolveFileInputToUrl } = vi.hoisted(() => ({
8+
mockHasCloudStorage: vi.fn(),
9+
mockResolveFileInputToUrl: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/uploads/core/storage-service', () => ({
13+
hasCloudStorage: mockHasCloudStorage,
14+
}))
15+
16+
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
17+
resolveFileInputToUrl: mockResolveFileInputToUrl,
18+
}))
19+
20+
import {
21+
INSTAGRAM_MEDIA_URL_TTL_SECONDS,
22+
resolveInstagramCarouselMedia,
23+
resolveInstagramMedia,
24+
} from '@/app/api/tools/instagram/resolve-media'
25+
26+
const logger = {} as Logger
27+
const context = {
28+
userId: 'user-1',
29+
requestId: 'request-1',
30+
logger,
31+
}
32+
33+
function uploadedFile(overrides: Record<string, unknown> = {}) {
34+
return {
35+
id: 'file-1',
36+
key: 'execution/workflow-1/execution-1/photo.jpg',
37+
name: 'photo.jpg',
38+
size: 1024,
39+
type: 'image/jpeg',
40+
...overrides,
41+
}
42+
}
43+
44+
beforeEach(() => {
45+
vi.clearAllMocks()
46+
mockHasCloudStorage.mockReturnValue(true)
47+
mockResolveFileInputToUrl.mockImplementation(
48+
async ({ file, filePath }: { file?: { name?: string }; filePath?: string }) => ({
49+
fileUrl: filePath || `https://signed.example.com/${file?.name || 'media'}`,
50+
})
51+
)
52+
})
53+
54+
describe('resolveInstagramMedia', () => {
55+
it('accepts a public HTTPS media URL', async () => {
56+
const result = await resolveInstagramMedia({
57+
...context,
58+
input: 'https://cdn.example.com/photo.jpg',
59+
role: 'image',
60+
})
61+
62+
expect(result).toEqual({
63+
media: expect.objectContaining({
64+
url: 'https://cdn.example.com/photo.jpg',
65+
kind: 'image',
66+
mimeType: 'image/jpeg',
67+
}),
68+
})
69+
})
70+
71+
it('rejects a public HTTP media URL before resolving it', async () => {
72+
const result = await resolveInstagramMedia({
73+
...context,
74+
input: 'http://cdn.example.com/photo.jpg',
75+
role: 'image',
76+
})
77+
78+
expect(result.error).toEqual({
79+
status: 400,
80+
message: 'Instagram media URLs must use HTTPS so Meta can download them',
81+
})
82+
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
83+
})
84+
85+
it('resolves an uploaded file with the Instagram publishing URL lifetime', async () => {
86+
const file = uploadedFile()
87+
const result = await resolveInstagramMedia({ ...context, input: file, role: 'image' })
88+
89+
expect(result.media).toEqual({
90+
url: 'https://signed.example.com/photo.jpg',
91+
kind: 'image',
92+
mimeType: 'image/jpeg',
93+
size: 1024,
94+
name: 'photo.jpg',
95+
})
96+
expect(mockResolveFileInputToUrl).toHaveBeenCalledWith({
97+
file,
98+
filePath: undefined,
99+
...context,
100+
presignExpirySeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS,
101+
})
102+
})
103+
104+
it('requires cloud storage for uploaded files and internal URLs', async () => {
105+
mockHasCloudStorage.mockReturnValue(false)
106+
107+
for (const input of [uploadedFile(), '/api/files/serve/execution/photo.jpg']) {
108+
const result = await resolveInstagramMedia({ ...context, input, role: 'image' })
109+
expect(result.error).toEqual({
110+
status: 400,
111+
message: expect.stringContaining('Cloud storage is required'),
112+
})
113+
}
114+
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
115+
})
116+
117+
it('validates JPEG MIME type and size without loading file bytes', async () => {
118+
const invalidType = await resolveInstagramMedia({
119+
...context,
120+
input: uploadedFile({ name: 'photo.png', type: 'image/png' }),
121+
role: 'image',
122+
label: 'Image',
123+
})
124+
const oversized = await resolveInstagramMedia({
125+
...context,
126+
input: uploadedFile({ size: 8 * 1024 * 1024 + 1 }),
127+
role: 'image',
128+
label: 'Image',
129+
})
130+
131+
expect(invalidType.error?.message).toBe('Image must be a JPEG image (got image/png)')
132+
expect(oversized.error?.message).toContain("Instagram's 8MB JPEG limit")
133+
})
134+
135+
it.each([
136+
{ role: 'video' as const, maxBytes: 300 * 1024 * 1024, label: 'Video' },
137+
{ role: 'story' as const, maxBytes: 100 * 1024 * 1024, label: 'Story' },
138+
])('enforces the $role video size limit', async ({ role, maxBytes, label }) => {
139+
const result = await resolveInstagramMedia({
140+
...context,
141+
input: uploadedFile({
142+
key: 'execution/workflow-1/execution-1/video.mp4',
143+
name: 'video.mp4',
144+
size: maxBytes + 1,
145+
type: 'video/mp4',
146+
}),
147+
role,
148+
label,
149+
})
150+
151+
expect(result.error?.message).toContain(`video limit for ${role}`)
152+
})
153+
154+
it('rejects unsupported video formats', async () => {
155+
const result = await resolveInstagramMedia({
156+
...context,
157+
input: uploadedFile({ name: 'video.webm', type: 'video/webm' }),
158+
role: 'video',
159+
label: 'Video',
160+
})
161+
162+
expect(result.error?.message).toBe('Video must be an MP4 or MOV video (got video/webm)')
163+
})
164+
})
165+
166+
describe('resolveInstagramCarouselMedia', () => {
167+
it('parses JSON input and infers image and video item types sequentially', async () => {
168+
let activeResolutions = 0
169+
let maxActiveResolutions = 0
170+
mockResolveFileInputToUrl.mockImplementation(async ({ filePath }: { filePath?: string }) => {
171+
activeResolutions += 1
172+
maxActiveResolutions = Math.max(maxActiveResolutions, activeResolutions)
173+
await Promise.resolve()
174+
activeResolutions -= 1
175+
return { fileUrl: filePath }
176+
})
177+
178+
const result = await resolveInstagramCarouselMedia(
179+
JSON.stringify([
180+
'https://cdn.example.com/carousel-1.jpg',
181+
'https://cdn.example.com/carousel-2.mp4',
182+
]),
183+
context.userId,
184+
context.requestId,
185+
logger
186+
)
187+
188+
expect(result.items?.map(({ url, kind }) => ({ url, kind }))).toEqual([
189+
{ url: 'https://cdn.example.com/carousel-1.jpg', kind: 'image' },
190+
{ url: 'https://cdn.example.com/carousel-2.mp4', kind: 'video' },
191+
])
192+
expect(maxActiveResolutions).toBe(1)
193+
})
194+
195+
it('supports legacy comma-separated URLs with an explicit video prefix', async () => {
196+
const result = await resolveInstagramCarouselMedia(
197+
'https://cdn.example.com/carousel-1.jpg, video:https://cdn.example.com/carousel-2.mp4',
198+
context.userId,
199+
context.requestId,
200+
logger
201+
)
202+
203+
expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video'])
204+
})
205+
206+
it('infers media types for uploaded file arrays', async () => {
207+
const result = await resolveInstagramCarouselMedia(
208+
[
209+
uploadedFile(),
210+
uploadedFile({
211+
id: 'file-2',
212+
key: 'execution/workflow-1/execution-1/video.mov',
213+
name: 'video.mov',
214+
type: 'video/quicktime',
215+
}),
216+
],
217+
context.userId,
218+
context.requestId,
219+
logger
220+
)
221+
222+
expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video'])
223+
})
224+
225+
it.each([
226+
{ count: 1, label: 'too few' },
227+
{ count: 11, label: 'too many' },
228+
])('rejects $label carousel items before resolving them', async ({ count }) => {
229+
const input = Array.from(
230+
{ length: count },
231+
(_, index) => `https://cdn.example.com/carousel-${index + 1}.jpg`
232+
)
233+
234+
const result = await resolveInstagramCarouselMedia(
235+
input,
236+
context.userId,
237+
context.requestId,
238+
logger
239+
)
240+
241+
expect(result.error).toEqual({
242+
status: 400,
243+
message: 'Carousels require between 2 and 10 items',
244+
})
245+
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
246+
})
247+
248+
it('rejects malformed carousel JSON', async () => {
249+
const result = await resolveInstagramCarouselMedia(
250+
'[not-json',
251+
context.userId,
252+
context.requestId,
253+
logger
254+
)
255+
256+
expect(result.error).toEqual({ status: 400, message: 'Carousel media JSON is invalid' })
257+
})
258+
})
File renamed without changes.

apps/sim/blocks/blocks/instagram.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { InstagramBlock } from '@/blocks/blocks/instagram'
5+
import { InstagramBlock, InstagramBlockMeta } from '@/blocks/blocks/instagram'
66

77
describe('InstagramBlock', () => {
88
const buildParams = InstagramBlock.tools.config.params!
@@ -12,6 +12,10 @@ describe('InstagramBlock', () => {
1212
expect(InstagramBlock.hideFromToolbar).toBe(true)
1313
})
1414

15+
it('provides the required integration templates', () => {
16+
expect(InstagramBlockMeta.templates).toHaveLength(7)
17+
})
18+
1519
it('clears stale operation parameters from the runtime input merge', () => {
1620
const inputs = {
1721
operation: 'instagram_get_conversation_messages',

apps/sim/blocks/blocks/instagram.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,24 @@ export const InstagramBlockMeta = {
11251125
category: 'marketing',
11261126
tags: ['marketing', 'content', 'automation'],
11271127
},
1128+
{
1129+
icon: InstagramIcon,
1130+
title: 'Instagram Story publishing queue',
1131+
prompt:
1132+
'Create a scheduled workflow that reads approved Story assets from a content table, publishes each image or video to Instagram Stories, checks the container status, and records the resulting media ID and outcome.',
1133+
modules: ['tables', 'scheduled', 'workflows'],
1134+
category: 'marketing',
1135+
tags: ['marketing', 'stories', 'automation'],
1136+
},
1137+
{
1138+
icon: InstagramIcon,
1139+
title: 'Instagram carousel campaign publisher',
1140+
prompt:
1141+
'Build a workflow that assembles two to ten approved images or videos into an Instagram carousel, publishes it with a campaign caption, checks processing status, and saves the published media ID to a campaign table.',
1142+
modules: ['tables', 'workflows'],
1143+
category: 'marketing',
1144+
tags: ['marketing', 'content', 'automation'],
1145+
},
11281146
{
11291147
icon: InstagramIcon,
11301148
title: 'Instagram comment moderator',
@@ -1152,6 +1170,15 @@ export const InstagramBlockMeta = {
11521170
category: 'marketing',
11531171
tags: ['marketing', 'analytics', 'automation'],
11541172
},
1173+
{
1174+
icon: InstagramIcon,
1175+
title: 'Instagram media archive',
1176+
prompt:
1177+
'Create a scheduled workflow that lists recent Instagram posts and Stories, downloads each media item as durable files, and records the source media ID, type, file references, and download status in an archive table.',
1178+
modules: ['scheduled', 'tables', 'workflows'],
1179+
category: 'operations',
1180+
tags: ['content', 'archive', 'automation'],
1181+
},
11551182
],
11561183
skills: [
11571184
{

0 commit comments

Comments
 (0)