Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/api/auth/oauth2/callback/instagram/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants'
import {
parseInstagramLongLivedToken,
parseInstagramProfile,
parseInstagramShortLivedToken,
} from '@/lib/oauth/instagram'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
import { INSTAGRAM_GRAPH_BASE } from '@/tools/instagram/constants'

const logger = createLogger('InstagramCallback')

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/tools/instagram/publish-carousel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { instagramPublishCarouselContract } from '@/lib/api/contracts/tools/inst
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramCarouselMedia } from '@/lib/integrations/instagram/resolve-media'
import { resolveInstagramCarouselMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/tools/instagram/publish-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { instagramPublishImageContract } from '@/lib/api/contracts/tools/instagr
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/tools/instagram/publish-reel/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { instagramPublishReelContract } from '@/lib/api/contracts/tools/instagra
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/tools/instagram/publish-story/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { instagramPublishStoryContract } from '@/lib/api/contracts/tools/instagr
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/tools/instagram/publish-video/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { instagramPublishVideoContract } from '@/lib/api/contracts/tools/instagr
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media'
import { resolveInstagramMedia } from '@/app/api/tools/instagram/resolve-media'
import {
createMediaContainer,
publishMediaContainer,
Expand Down
258 changes: 258 additions & 0 deletions apps/sim/app/api/tools/instagram/resolve-media.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/**
* @vitest-environment node
*/
import type { Logger } from '@sim/logger'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockHasCloudStorage, mockResolveFileInputToUrl } = vi.hoisted(() => ({
mockHasCloudStorage: vi.fn(),
mockResolveFileInputToUrl: vi.fn(),
}))

vi.mock('@/lib/uploads/core/storage-service', () => ({
hasCloudStorage: mockHasCloudStorage,
}))

vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
resolveFileInputToUrl: mockResolveFileInputToUrl,
}))

import {
INSTAGRAM_MEDIA_URL_TTL_SECONDS,
resolveInstagramCarouselMedia,
resolveInstagramMedia,
} from '@/app/api/tools/instagram/resolve-media'

const logger = {} as Logger
const context = {
userId: 'user-1',
requestId: 'request-1',
logger,
}

function uploadedFile(overrides: Record<string, unknown> = {}) {
return {
id: 'file-1',
key: 'execution/workflow-1/execution-1/photo.jpg',
name: 'photo.jpg',
size: 1024,
type: 'image/jpeg',
...overrides,
}
}

beforeEach(() => {
vi.clearAllMocks()
mockHasCloudStorage.mockReturnValue(true)
mockResolveFileInputToUrl.mockImplementation(
async ({ file, filePath }: { file?: { name?: string }; filePath?: string }) => ({
fileUrl: filePath || `https://signed.example.com/${file?.name || 'media'}`,
})
)
})

describe('resolveInstagramMedia', () => {
it('accepts a public HTTPS media URL', async () => {
const result = await resolveInstagramMedia({
...context,
input: 'https://cdn.example.com/photo.jpg',
role: 'image',
})

expect(result).toEqual({
media: expect.objectContaining({
url: 'https://cdn.example.com/photo.jpg',
kind: 'image',
mimeType: 'image/jpeg',
}),
})
})

it('rejects a public HTTP media URL before resolving it', async () => {
const result = await resolveInstagramMedia({
...context,
input: 'http://cdn.example.com/photo.jpg',
role: 'image',
})

expect(result.error).toEqual({
status: 400,
message: 'Instagram media URLs must use HTTPS so Meta can download them',
})
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
})

it('resolves an uploaded file with the Instagram publishing URL lifetime', async () => {
const file = uploadedFile()
const result = await resolveInstagramMedia({ ...context, input: file, role: 'image' })

expect(result.media).toEqual({
url: 'https://signed.example.com/photo.jpg',
kind: 'image',
mimeType: 'image/jpeg',
size: 1024,
name: 'photo.jpg',
})
expect(mockResolveFileInputToUrl).toHaveBeenCalledWith({
file,
filePath: undefined,
...context,
presignExpirySeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS,
})
})

it('requires cloud storage for uploaded files and internal URLs', async () => {
mockHasCloudStorage.mockReturnValue(false)

for (const input of [uploadedFile(), '/api/files/serve/execution/photo.jpg']) {
const result = await resolveInstagramMedia({ ...context, input, role: 'image' })
expect(result.error).toEqual({
status: 400,
message: expect.stringContaining('Cloud storage is required'),
})
}
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
})

it('validates JPEG MIME type and size without loading file bytes', async () => {
const invalidType = await resolveInstagramMedia({
...context,
input: uploadedFile({ name: 'photo.png', type: 'image/png' }),
role: 'image',
label: 'Image',
})
const oversized = await resolveInstagramMedia({
...context,
input: uploadedFile({ size: 8 * 1024 * 1024 + 1 }),
role: 'image',
label: 'Image',
})

expect(invalidType.error?.message).toBe('Image must be a JPEG image (got image/png)')
expect(oversized.error?.message).toContain("Instagram's 8MB JPEG limit")
})

it.each([
{ role: 'video' as const, maxBytes: 300 * 1024 * 1024, label: 'Video' },
{ role: 'story' as const, maxBytes: 100 * 1024 * 1024, label: 'Story' },
])('enforces the $role video size limit', async ({ role, maxBytes, label }) => {
const result = await resolveInstagramMedia({
...context,
input: uploadedFile({
key: 'execution/workflow-1/execution-1/video.mp4',
name: 'video.mp4',
size: maxBytes + 1,
type: 'video/mp4',
}),
role,
label,
})

expect(result.error?.message).toContain(`video limit for ${role}`)
})

it('rejects unsupported video formats', async () => {
const result = await resolveInstagramMedia({
...context,
input: uploadedFile({ name: 'video.webm', type: 'video/webm' }),
role: 'video',
label: 'Video',
})

expect(result.error?.message).toBe('Video must be an MP4 or MOV video (got video/webm)')
})
})

describe('resolveInstagramCarouselMedia', () => {
it('parses JSON input and infers image and video item types sequentially', async () => {
let activeResolutions = 0
let maxActiveResolutions = 0
mockResolveFileInputToUrl.mockImplementation(async ({ filePath }: { filePath?: string }) => {
activeResolutions += 1
maxActiveResolutions = Math.max(maxActiveResolutions, activeResolutions)
await Promise.resolve()
activeResolutions -= 1
return { fileUrl: filePath }
})

const result = await resolveInstagramCarouselMedia(
JSON.stringify([
'https://cdn.example.com/carousel-1.jpg',
'https://cdn.example.com/carousel-2.mp4',
]),
context.userId,
context.requestId,
logger
)

expect(result.items?.map(({ url, kind }) => ({ url, kind }))).toEqual([
{ url: 'https://cdn.example.com/carousel-1.jpg', kind: 'image' },
{ url: 'https://cdn.example.com/carousel-2.mp4', kind: 'video' },
])
expect(maxActiveResolutions).toBe(1)
})

it('supports legacy comma-separated URLs with an explicit video prefix', async () => {
const result = await resolveInstagramCarouselMedia(
'https://cdn.example.com/carousel-1.jpg, video:https://cdn.example.com/carousel-2.mp4',
context.userId,
context.requestId,
logger
)

expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video'])
})

it('infers media types for uploaded file arrays', async () => {
const result = await resolveInstagramCarouselMedia(
[
uploadedFile(),
uploadedFile({
id: 'file-2',
key: 'execution/workflow-1/execution-1/video.mov',
name: 'video.mov',
type: 'video/quicktime',
}),
],
context.userId,
context.requestId,
logger
)

expect(result.items?.map(({ kind }) => kind)).toEqual(['image', 'video'])
})

it.each([
{ count: 1, label: 'too few' },
{ count: 11, label: 'too many' },
])('rejects $label carousel items before resolving them', async ({ count }) => {
const input = Array.from(
{ length: count },
(_, index) => `https://cdn.example.com/carousel-${index + 1}.jpg`
)

const result = await resolveInstagramCarouselMedia(
input,
context.userId,
context.requestId,
logger
)

expect(result.error).toEqual({
status: 400,
message: 'Carousels require between 2 and 10 items',
})
expect(mockResolveFileInputToUrl).not.toHaveBeenCalled()
})

it('rejects malformed carousel JSON', async () => {
const result = await resolveInstagramCarouselMedia(
'[not-json',
context.userId,
context.requestId,
logger
)

expect(result.error).toEqual({ status: 400, message: 'Carousel media JSON is invalid' })
})
})
6 changes: 5 additions & 1 deletion apps/sim/blocks/blocks/instagram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { InstagramBlock } from '@/blocks/blocks/instagram'
import { InstagramBlock, InstagramBlockMeta } from '@/blocks/blocks/instagram'

describe('InstagramBlock', () => {
const buildParams = InstagramBlock.tools.config.params!
Expand All @@ -12,6 +12,10 @@ describe('InstagramBlock', () => {
expect(InstagramBlock.hideFromToolbar).toBe(true)
})

it('provides the required integration templates', () => {
expect(InstagramBlockMeta.templates).toHaveLength(7)
})

it('clears stale operation parameters from the runtime input merge', () => {
const inputs = {
operation: 'instagram_get_conversation_messages',
Expand Down
27 changes: 27 additions & 0 deletions apps/sim/blocks/blocks/instagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,24 @@ export const InstagramBlockMeta = {
category: 'marketing',
tags: ['marketing', 'content', 'automation'],
},
{
icon: InstagramIcon,
title: 'Instagram Story publishing queue',
prompt:
'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.',
modules: ['tables', 'scheduled', 'workflows'],
category: 'marketing',
tags: ['marketing', 'stories', 'automation'],
},
{
icon: InstagramIcon,
title: 'Instagram carousel campaign publisher',
prompt:
'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.',
modules: ['tables', 'workflows'],
category: 'marketing',
tags: ['marketing', 'content', 'automation'],
},
{
icon: InstagramIcon,
title: 'Instagram comment moderator',
Expand Down Expand Up @@ -1152,6 +1170,15 @@ export const InstagramBlockMeta = {
category: 'marketing',
tags: ['marketing', 'analytics', 'automation'],
},
{
icon: InstagramIcon,
title: 'Instagram media archive',
prompt:
'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.',
modules: ['scheduled', 'tables', 'workflows'],
category: 'operations',
tags: ['content', 'archive', 'automation'],
},
],
skills: [
{
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/instagram/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
readResponseJsonWithLimit,
readResponseTextWithLimit,
} from '@/lib/core/utils/stream-limits'
import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants'
import { INSTAGRAM_GRAPH_BASE } from '@/tools/instagram/constants'
import type { InstagramPublishResponse } from '@/tools/instagram/types'

export const INSTAGRAM_RESPONSE_MAX_BYTES = 2 * 1024 * 1024
Expand Down
Loading