diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index d1a5cdd0b46..c016a87c49c 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -17,7 +17,6 @@ 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, @@ -25,6 +24,7 @@ import { } 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') diff --git a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts index a1f73b9d6af..95920fd4475 100644 --- a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/instagram/publish-image/route.ts b/apps/sim/app/api/tools/instagram/publish-image/route.ts index 8b97b5c9228..2d6af6d1a55 100644 --- a/apps/sim/app/api/tools/instagram/publish-image/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-image/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/instagram/publish-reel/route.ts b/apps/sim/app/api/tools/instagram/publish-reel/route.ts index b68a23da438..a33b6bfa98e 100644 --- a/apps/sim/app/api/tools/instagram/publish-reel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-reel/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/instagram/publish-story/route.ts b/apps/sim/app/api/tools/instagram/publish-story/route.ts index bee2b6ed62e..2d1717e12a7 100644 --- a/apps/sim/app/api/tools/instagram/publish-story/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-story/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/instagram/publish-video/route.ts b/apps/sim/app/api/tools/instagram/publish-video/route.ts index d2eb3fe5b3e..ab805234ce6 100644 --- a/apps/sim/app/api/tools/instagram/publish-video/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-video/route.ts @@ -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, diff --git a/apps/sim/app/api/tools/instagram/resolve-media.test.ts b/apps/sim/app/api/tools/instagram/resolve-media.test.ts new file mode 100644 index 00000000000..a72c56fa28f --- /dev/null +++ b/apps/sim/app/api/tools/instagram/resolve-media.test.ts @@ -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 = {}) { + 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' }) + }) +}) diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/app/api/tools/instagram/resolve-media.ts similarity index 100% rename from apps/sim/lib/integrations/instagram/resolve-media.ts rename to apps/sim/app/api/tools/instagram/resolve-media.ts diff --git a/apps/sim/blocks/blocks/instagram.test.ts b/apps/sim/blocks/blocks/instagram.test.ts index d6f12933a03..1e633446711 100644 --- a/apps/sim/blocks/blocks/instagram.test.ts +++ b/apps/sim/blocks/blocks/instagram.test.ts @@ -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! @@ -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', diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index 2c346d00ce6..1fc62a2df34 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -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', @@ -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: [ { diff --git a/apps/sim/lib/integrations/instagram/constants.ts b/apps/sim/tools/instagram/constants.ts similarity index 100% rename from apps/sim/lib/integrations/instagram/constants.ts rename to apps/sim/tools/instagram/constants.ts diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index 6e4a7777672..176affa8209 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -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