diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 4c58dddc322..9152be891dd 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1467,6 +1467,21 @@ export function LinkedInIcon(props: SVGProps) { ) } +/** Placeholder Instagram glyph — replace with brand SVG from press kit when provided. */ +export function InstagramIcon(props: SVGProps) { + return ( + + ) +} + export function CrunchbaseIcon(props: SVGProps) { return ( = { imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, + instagram: InstagramIcon, instantly: InstantlyIcon, intercom: IntercomIcon, intercom_v2: IntercomIcon, diff --git a/apps/docs/content/docs/en/integrations/instagram.mdx b/apps/docs/content/docs/en/integrations/instagram.mdx new file mode 100644 index 00000000000..29da3afe9f9 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/instagram.mdx @@ -0,0 +1,655 @@ +--- +title: Instagram +description: Publish content, moderate comments, and manage Instagram DMs +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Instagram into workflows. Publish images, videos, Reels, stories, and carousels; moderate comments; send DMs; and pull account or media insights. + + + +## Actions + +### `instagram_get_profile` + +Get the connected Instagram professional account profile + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | + +### `instagram_list_media` + +List recent media on the Instagram professional account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `limit` | number | No | Max number of media items to return \(default 25, max 100\) | +| `after` | string | No | Pagination cursor from a previous list_media response | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `nextCursor` | string | Pagination cursor for the next page | + +### `instagram_get_media` + +Get details for a specific Instagram media object + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `mediaId` | string | Yes | Instagram media id | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Media id | +| `caption` | string | Caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | + +### `instagram_list_stories` + +List active stories on the Instagram professional account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | + +### `instagram_publish_image` + +Create and publish a single JPEG image post from an uploaded file or public HTTPS URL (polls until the container is ready) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `image` | file | Yes | JPEG image file or public HTTPS URL \(Meta will download it\) | +| `caption` | string | No | Post caption \(max 2200 characters\) | +| `altText` | string | No | Accessibility alt text for the image | +| `isAiGenerated` | boolean | No | Mark the post as AI-generated | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | + +### `instagram_publish_video` + +Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `video` | file | Yes | Video file or public HTTPS URL | +| `caption` | string | No | Post caption | +| `cover` | file | No | Optional JPEG cover image file or public HTTPS URL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | + +### `instagram_publish_reel` + +Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `video` | file | Yes | Reel video file or public HTTPS URL | +| `caption` | string | No | Reel caption | +| `cover` | file | No | Optional JPEG cover image file or public HTTPS URL | +| `shareToFeed` | boolean | No | Also share the Reel to the main feed | +| `thumbOffset` | number | No | Frame offset in milliseconds for the cover thumbnail | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | + +### `instagram_publish_story` + +Publish an image or video story from an uploaded file or public HTTPS URL + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `media` | file | Yes | JPEG image or MP4/MOV video file, or a public HTTPS URL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | + +### `instagram_publish_carousel` + +Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `media` | file[] | Yes | 2-10 media files, or a comma-separated public URL string \(prefix videos with video:\) | +| `caption` | string | No | Carousel caption | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | + +### `instagram_get_container_status` + +Check the publishing status of a media container + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `containerId` | string | Yes | Media container id returned from a create/publish step | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `containerId` | string | Container id | +| `statusCode` | string | EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED | +| `status` | string | Detailed status message when available | + +### `instagram_get_publishing_limit` + +Check the content publishing rate limit usage for the account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `quotaUsage` | number | Number of publishes used in the current window | +| `config` | json | Quota config \(quotaTotal, quotaDuration\) | + +### `instagram_list_comments` + +List comments on an Instagram media object + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `mediaId` | string | Yes | Instagram media id | +| `limit` | number | No | Max number of comments to return \(default 25, max 100\) | +| `after` | string | No | Pagination cursor | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `nextCursor` | string | Pagination cursor | + +### `instagram_reply_to_comment` + +Reply to a comment on Instagram media + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | Comment id to reply to | +| `message` | string | Yes | Reply text | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Created reply comment id | + +### `instagram_hide_comment` + +Hide or unhide a comment on Instagram media + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | Comment id | +| `hide` | boolean | Yes | True to hide, false to unhide | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the hide/unhide succeeded | + +### `instagram_delete_comment` + +Delete a comment on Instagram media + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `commentId` | string | Yes | Comment id to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the delete succeeded | + +### `instagram_set_comments_enabled` + +Enable or disable comments on an Instagram media object + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `mediaId` | string | Yes | Instagram media id | +| `commentEnabled` | boolean | Yes | True to enable comments, false to disable | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the update succeeded | + +### `instagram_private_reply` + +Send a private Direct message reply to a commenter (one per commenter) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `commentId` | string | Yes | Comment id to privately reply to | +| `message` | string | Yes | Private reply text | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messageId` | string | Sent message id | +| `recipientId` | string | Instagram-scoped recipient id | + +### `instagram_list_conversations` + +List Instagram Direct conversations for the professional account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `limit` | number | No | Max number of conversations to return \(default 25, max 100\) | +| `after` | string | No | Pagination cursor | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `nextCursor` | string | Pagination cursor | + +### `instagram_get_conversation_messages` + +List recent message ids/content in a conversation (only the last ~20 messages are fetchable) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `conversationId` | string | Yes | Conversation id from list_conversations | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\). Use Get Message for sender, recipient, and text. | + +### `instagram_get_message` + +Get a single Instagram Direct message by id (only recent messages are available) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | string | Yes | Message id | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | Message id | +| `createdTime` | string | Created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | + +### `instagram_send_text_message` + +Send a text Direct message. The recipient must have messaged the account first (24h window). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `recipientId` | string | Yes | Instagram-scoped user id \(IGSID\) of the recipient | +| `message` | string | Yes | Message text \(max 1000 bytes UTF-8\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `messageId` | string | Sent message id | +| `recipientId` | string | Recipient id | + +### `instagram_get_account_insights` + +Get insights metrics for the Instagram professional account + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `metrics` | string | Yes | Comma-separated metrics \(e.g. reach,views,accounts_engaged,profile_views,follower_count\) | +| `period` | string | Yes | Aggregation period: day, week, days_28, lifetime, or total_over_range | +| `since` | string | No | Unix timestamp or date for range start | +| `until` | string | No | Unix timestamp or date for range end | +| `metricType` | string | No | Optional metric_type \(e.g. time_series, total_value\) | +| `breakdown` | string | No | Optional breakdown dimension | +| `timeframe` | string | No | Required lookback window for demographic metrics | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | + +### `instagram_get_media_insights` + +Get insights metrics for a specific Instagram media object + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `mediaId` | string | Yes | Instagram media id | +| `metrics` | string | Yes | Comma-separated metrics \(e.g. views,reach,likes,comments,saved,shares,total_interactions\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `insights` | json | Media insight metrics \(name, period, title, description, values, totalValue\) | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 85ab2e676bc..445e3cc8b5f 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -111,6 +111,7 @@ "imap", "incidentio", "infisical", + "instagram", "instantly", "intercom", "jina", diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 9366575fd54..bed4e2e3054 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -96,6 +96,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # S3_ENDPOINT= # Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3 # S3_FORCE_PATH_STYLE=true # Required for MinIO/Ceph RGW. Leave unset for AWS S3 and R2 +# Instagram OAuth (Optional - Instagram App ID/Secret from Meta App Dashboard > Instagram > API setup with Instagram login) +# INSTAGRAM_CLIENT_ID= +# INSTAGRAM_CLIENT_SECRET= +# Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL). +# Gmail attachments do not need this. # Azure Blob Storage takes precedence over S3 if both are configured # AZURE_ACCOUNT_NAME= # Azure storage account name # AZURE_ACCOUNT_KEY= # Azure storage account key diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts new file mode 100644 index 00000000000..85b4437748a --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InstagramAuthorize') + +export const dynamic = 'force-dynamic' + +const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' +const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' +const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 + +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(authorizeInstagramContract, request, {}) + if (!parsed.success) return parsed.response + const { returnUrl, workspaceId } = parsed.data.query + + if (workspaceId) { + const access = await checkWorkspaceAccess(workspaceId, session.user.id) + if (!access.canWrite) { + return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + } + await createConnectDraft({ + userId: session.user.id, + workspaceId, + providerId: 'instagram', + }) + } + + const clientId = env.INSTAGRAM_CLIENT_ID + if (!clientId) { + logger.error('INSTAGRAM_CLIENT_ID not configured') + return NextResponse.json({ error: 'Instagram client ID not configured' }, { status: 500 }) + } + + const baseUrl = getBaseUrl() + const state = generateShortId(32) + const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram` + const scope = getCanonicalScopesForProvider('instagram').join(',') + + const authUrl = new URL('https://www.instagram.com/oauth/authorize') + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('redirect_uri', redirectUri) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', scope) + authUrl.searchParams.set('state', state) + + const response = NextResponse.redirect(authUrl.toString()) + response.cookies.set(INSTAGRAM_STATE_COOKIE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + + if (returnUrl && isSameOrigin(returnUrl)) { + response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } + + return response + } catch (error) { + logger.error('Error starting Instagram OAuth', { error }) + return NextResponse.json({ error: 'Failed to start Instagram OAuth' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 367821cb7d0..a5392ff2d57 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -8,6 +8,7 @@ import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' import { refreshOAuthToken } from '@/lib/oauth' +import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram' import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider, @@ -546,6 +547,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise accessTokenExpiresAt: account.accessTokenExpiresAt, idToken: account.idToken, scope: account.scope, + updatedAt: account.updatedAt, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) @@ -559,19 +561,33 @@ export async function getOAuthToken(userId: string, providerId: string): Promise const credential = connections[0] - // Determine whether we should refresh: missing token OR expired token + // Determine whether we should refresh: missing/expired token, or Instagram + // long-lived token nearing expiry (Meta cannot refresh after expiry). const now = new Date() const tokenExpiry = credential.accessTokenExpiresAt - const shouldAttemptRefresh = + const accessTokenNeedsRefresh = !!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now)) + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: credential.accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) - if (shouldAttemptRefresh) { - return performCoalescedRefresh({ + if (accessTokenNeedsRefresh || instagramNeedsProactiveRefresh) { + const fresh = await performCoalescedRefresh({ accountId: credential.id, providerId, refreshToken: credential.refreshToken!, userId, }) + if (fresh) return fresh + if (!accessTokenNeedsRefresh && credential.accessToken) { + return credential.accessToken + } + return null } if (!credential.accessToken) { @@ -645,7 +661,18 @@ export async function refreshAccessTokenIfNeeded( refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold - const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh + // Instagram long-lived tokens can only be refreshed while still valid. + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(credential.providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) + + const shouldRefresh = + accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh const accessToken = credential.accessToken @@ -662,8 +689,8 @@ export async function refreshAccessTokenIfNeeded( }) if (fresh) return fresh - // If refresh was only triggered proactively (Microsoft refresh-token aging), - // the still-valid access token is a fine fallback. + // If refresh was only triggered proactively (Microsoft refresh-token aging / + // Instagram long-lived nearing expiry), the still-valid access token is fine. if (!accessTokenNeedsRefresh && accessToken) { logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`) return accessToken @@ -711,7 +738,18 @@ export async function refreshTokenIfNeeded( refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold - const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh + // Instagram long-lived tokens can only be refreshed while still valid. + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(credential.providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) + + const shouldRefresh = + accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh // If token appears valid and present, return it directly if (!shouldRefresh) { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 65bb6a773b9..2a9799d7863 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -1,81 +1,17 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getAllOAuthServices } from '@/lib/oauth/utils' +import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') export const dynamic = 'force-dynamic' -const DRAFT_TTL_MS = 15 * 60 * 1000 - -/** - * Creates the pending credential draft at click time so its TTL starts when the - * user actually initiates the connect. Better Auth's `account.create.after` hook - * consumes this draft to materialize the real credential after the OAuth - * callback; starting the clock here guarantees the draft outlives the (≤5 min) - * OAuth round-trip rather than expiring mid-flow and silently producing no - * credential. - */ -async function createConnectDraft(params: { - userId: string - workspaceId: string - providerId: string -}): Promise { - const { userId, workspaceId, providerId } = params - - const service = getAllOAuthServices().find((s) => s.providerId === providerId) - const serviceName = service?.name ?? providerId - - let displayName = serviceName - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - if (row?.name) { - displayName = `${row.name}'s ${serviceName}` - } - } catch { - // Fall back to service name only - } - - const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - expiresAt, - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { displayName, expiresAt, createdAt: now }, - }) - - logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) -} - /** * Browser-initiated entrypoint for linking a generic OAuth2 account. */ diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts new file mode 100644 index 00000000000..42e56365b5c --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -0,0 +1,324 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramCallbackContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +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' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { safeAccountInsert } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('InstagramCallback') + +export const dynamic = 'force-dynamic' + +const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' +const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' +interface ShortLivedTokenPayload { + access_token?: string + user_id?: string | number + // Meta returns granted permissions as a comma-separated string or an array + // depending on the response shape. + permissions?: string | string[] +} + +function unwrapShortLivedToken(body: unknown): ShortLivedTokenPayload | null { + if (!body || typeof body !== 'object') return null + + const record = body as Record + + // Nested shape from Instagram Login: { data: [{ access_token, user_id, permissions }] } + if (Array.isArray(record.data) && record.data.length > 0) { + const first = record.data[0] + if (first && typeof first === 'object') { + return first as ShortLivedTokenPayload + } + } + + // Flat shape fallback + if (typeof record.access_token === 'string') { + return record as ShortLivedTokenPayload + } + + return null +} + +function clearOAuthCookies(response: NextResponse) { + response.cookies.delete({ name: INSTAGRAM_STATE_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + response.cookies.delete({ name: INSTAGRAM_RETURN_URL_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + return response +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const baseUrl = getBaseUrl() + + try { + const session = await getSession() + if (!session?.user?.id) { + return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)) + } + + const parsed = await parseRequest(instagramCallbackContract, request, {}) + if (!parsed.success) return parsed.response + + const { code, state, error, error_reason, error_description } = parsed.data.query + + if (error) { + logger.warn('Instagram OAuth denied by user', { + error, + error_reason, + error_description, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_access_denied`) + ) + } + + const cookieState = request.cookies.get(INSTAGRAM_STATE_COOKIE)?.value + if (!state || !cookieState || state !== cookieState) { + logger.warn('Instagram callback rejected: state mismatch', { + hasQueryState: Boolean(state), + hasCookieState: Boolean(cookieState), + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_state_mismatch`) + ) + } + + const clientId = env.INSTAGRAM_CLIENT_ID + const clientSecret = env.INSTAGRAM_CLIENT_SECRET + if (!clientId || !clientSecret) { + logger.error('Instagram credentials not configured') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_config_error`) + ) + } + + if (!code) { + logger.error('No authorization code received from Instagram') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_code`) + ) + } + + // Instagram appends `#_` to the redirect; strip it from the code if present. + const authorizationCode = code.replace(/#_$/, '') + const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram` + + const tokenForm = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + code: authorizationCode, + }) + + const shortLivedResponse = await fetch('https://api.instagram.com/oauth/access_token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenForm.toString(), + }) + + if (!shortLivedResponse.ok) { + const errorText = await shortLivedResponse.text() + logger.error('Failed to exchange Instagram authorization code', { + status: shortLivedResponse.status, + body: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`) + ) + } + + const shortLivedBody = await shortLivedResponse.json() + const shortLived = unwrapShortLivedToken(shortLivedBody) + if (!shortLived?.access_token) { + logger.error('Instagram short-lived token response missing access_token', { + body: shortLivedBody, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`) + ) + } + + const exchangeUrl = new URL('https://graph.instagram.com/access_token') + exchangeUrl.searchParams.set('grant_type', 'ig_exchange_token') + exchangeUrl.searchParams.set('client_secret', clientSecret) + exchangeUrl.searchParams.set('access_token', shortLived.access_token) + + const longLivedResponse = await fetch(exchangeUrl.toString(), { method: 'GET' }) + if (!longLivedResponse.ok) { + const errorText = await longLivedResponse.text() + logger.error('Failed to exchange Instagram short-lived token', { + status: longLivedResponse.status, + body: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`) + ) + } + + const longLivedBody = (await longLivedResponse.json()) as { + access_token?: string + token_type?: string + expires_in?: number + } + + if (!longLivedBody.access_token) { + logger.error('Instagram long-lived token response missing access_token', { + body: longLivedBody, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`) + ) + } + + const longLivedToken = longLivedBody.access_token + const expiresIn = longLivedBody.expires_in ?? 5184000 + + const profileResponse = await fetch( + `${INSTAGRAM_GRAPH_BASE}/me?fields=user_id,username,name,account_type,profile_picture_url&access_token=${encodeURIComponent(longLivedToken)}` + ) + + if (!profileResponse.ok) { + const errorText = await profileResponse.text() + logger.error('Failed to fetch Instagram profile after OAuth', { + status: profileResponse.status, + body: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + ) + } + + const profile = (await profileResponse.json()) as { + user_id?: string | number + id?: string + username?: string + name?: string + } + + // account.accountId must always be the Instagram professional account ID + // (/me user_id, which Meta may serialize as a string or number). The token + // exchange's user_id and /me's id are app-scoped IDs — a different ID + // space — so falling back to them would break reconnect dedupe by storing + // the same account under two identifiers. + const igUserId = + profile.user_id != null && profile.user_id !== '' ? String(profile.user_id) : null + + if (!igUserId) { + logger.error('Instagram profile response missing user_id', { profile }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`) + ) + } + + let grantedPermissions: string[] = [] + if (Array.isArray(shortLived.permissions)) { + grantedPermissions = shortLived.permissions.filter( + (s): s is string => typeof s === 'string' && s.length > 0 + ) + } else if (typeof shortLived.permissions === 'string' && shortLived.permissions.length > 0) { + grantedPermissions = shortLived.permissions + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + const permissions = + grantedPermissions.length > 0 + ? grantedPermissions + : getCanonicalScopesForProvider('instagram') + // Space-joined: the standard OAuth format both credential routes parse + // (`/api/auth/oauth/connections` splits on whitespace only). + const scope = permissions.join(' ') + + const now = new Date() + const accessTokenExpiresAt = new Date(now.getTime() + expiresIn * 1000) + + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, session.user.id), + eq(account.providerId, 'instagram'), + eq(account.accountId, igUserId) + ), + }) + + if (existing) { + await db + .update(account) + .set({ + accessToken: longLivedToken, + refreshToken: longLivedToken, + accessTokenExpiresAt, + scope, + updatedAt: now, + }) + .where(eq(account.id, existing.id)) + logger.info('Updated existing Instagram account', { + accountId: existing.id, + igUserId, + username: profile.username, + }) + } else { + await safeAccountInsert( + { + id: `instagram_${session.user.id}_${Date.now()}`, + userId: session.user.id, + providerId: 'instagram', + accountId: igUserId, + accessToken: longLivedToken, + refreshToken: longLivedToken, + accessTokenExpiresAt, + scope, + createdAt: now, + updatedAt: now, + }, + { provider: 'Instagram', identifier: profile.username || igUserId } + ) + logger.info('Created Instagram account', { igUserId, username: profile.username }) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, session.user.id), + eq(account.providerId, 'instagram'), + eq(account.accountId, igUserId) + ), + })) + + if (persisted) { + try { + await processCredentialDraft({ + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) + } catch (draftError) { + logger.error('Failed to process credential draft for Instagram', { error: draftError }) + } + } + + const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value + const redirectUrl = + returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('instagram_connected', 'true') + + return clearOAuthCookies(NextResponse.redirect(finalUrl.toString())) + } catch (error) { + logger.error('Error in Instagram OAuth callback', { error }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_callback_error`) + ) + } +}) diff --git a/apps/sim/app/api/files/storage-status/route.ts b/apps/sim/app/api/files/storage-status/route.ts new file mode 100644 index 00000000000..58dcadbdeb4 --- /dev/null +++ b/apps/sim/app/api/files/storage-status/route.ts @@ -0,0 +1,24 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' + +export const dynamic = 'force-dynamic' + +/** + * GET /api/files/storage-status + * Whether S3 or Azure Blob is configured (needed for Instagram file-upload publish). + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(fileStorageStatusContract, request, {}) + if (!parsed.success) return parsed.response + + return NextResponse.json({ cloudConfigured: hasCloudStorage() }) +}) diff --git a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts new file mode 100644 index 00000000000..95fd745cbdc --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -0,0 +1,117 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishCarouselContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramCarouselMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** + * Children are polled in parallel, so the worst case is one five-minute poll + * window for the children plus another for the parent container. + */ +export const maxDuration = 900 + +const logger = createLogger('InstagramPublishCarouselAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish carousel: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishCarouselContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolved = await resolveInstagramCarouselMedia( + body.media, + authResult.userId, + requestId, + logger + ) + if (resolved.error || !resolved.items) { + return NextResponse.json( + { + success: false, + error: resolved.error?.message || 'Failed to resolve carousel media', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolved.error?.status || 400 } + ) + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + + // Create every child container before polling any of them: Meta fetches each + // media URL at container creation, so presigned links are consumed while + // fresh, and polling in parallel bounds the total wait to a single + // five-minute window instead of five minutes per item. + const childIds: string[] = [] + for (const item of resolved.items) { + const childBody: Record = { + is_carousel_item: true, + } + if (item.kind === 'video') { + childBody.media_type = 'VIDEO' + childBody.video_url = item.url + } else { + childBody.image_url = item.url + } + childIds.push(await createMediaContainer(body.accessToken, igUserId, childBody)) + } + + // allSettled so a fast-failing child doesn't leave sibling polls rejecting + // with no handler (Node terminates on unhandled rejections). + const childResults = await Promise.allSettled( + childIds.map((childId) => waitForContainerReady(body.accessToken, childId)) + ) + const failedChild = childResults.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (failedChild) { + throw failedChild.reason + } + + const parentBody: Record = { + media_type: 'CAROUSEL', + children: childIds.join(','), + } + if (body.caption) parentBody.caption = body.caption + + const containerId = await createMediaContainer(body.accessToken, igUserId, parentBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish carousel failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish carousel'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-image/route.ts b/apps/sim/app/api/tools/instagram/publish-image/route.ts new file mode 100644 index 00000000000..137c967e7a2 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-image/route.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishImageContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishImageAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish image: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishImageContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolved = await resolveInstagramMedia({ + input: body.image, + userId: authResult.userId, + requestId, + logger, + role: 'image', + label: 'Image', + }) + if (resolved.error || !resolved.media) { + return NextResponse.json( + { + success: false, + error: resolved.error?.message || 'Failed to resolve image', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolved.error?.status || 400 } + ) + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const containerBody: Record = { + image_url: resolved.media.url, + } + if (body.caption) containerBody.caption = body.caption + if (body.altText) containerBody.alt_text = body.altText + if (body.isAiGenerated === true) containerBody.is_ai_generated = true + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish image failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish image'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-reel/route.ts b/apps/sim/app/api/tools/instagram/publish-reel/route.ts new file mode 100644 index 00000000000..11f06f8720d --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-reel/route.ts @@ -0,0 +1,114 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishReelContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishReelAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish reel: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishReelContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolvedVideo = await resolveInstagramMedia({ + input: body.video, + userId: authResult.userId, + requestId, + logger, + role: 'video', + label: 'Video', + }) + if (resolvedVideo.error || !resolvedVideo.media) { + return NextResponse.json( + { + success: false, + error: resolvedVideo.error?.message || 'Failed to resolve video', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedVideo.error?.status || 400 } + ) + } + + let coverUrl: string | undefined + if (body.cover != null && body.cover !== '') { + const resolvedCover = await resolveInstagramMedia({ + input: body.cover, + userId: authResult.userId, + requestId, + logger, + role: 'cover', + required: false, + label: 'Cover image', + }) + if (resolvedCover.error) { + return NextResponse.json( + { + success: false, + error: resolvedCover.error.message, + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedCover.error.status } + ) + } + coverUrl = resolvedCover.media?.url + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const containerBody: Record = { + media_type: 'REELS', + video_url: resolvedVideo.media.url, + } + if (body.caption) containerBody.caption = body.caption + if (coverUrl) containerBody.cover_url = coverUrl + if (body.shareToFeed !== undefined && body.shareToFeed !== null) { + containerBody.share_to_feed = body.shareToFeed + } + if (body.thumbOffset != null) containerBody.thumb_offset = body.thumbOffset + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish reel failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish reel'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-story/route.ts b/apps/sim/app/api/tools/instagram/publish-story/route.ts new file mode 100644 index 00000000000..bc7ca629035 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-story/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishStoryContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishStoryAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish story: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishStoryContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolved = await resolveInstagramMedia({ + input: body.media, + userId: authResult.userId, + requestId, + logger, + role: 'story', + label: 'Story media', + }) + if (resolved.error || !resolved.media) { + return NextResponse.json( + { + success: false, + error: resolved.error?.message || 'Failed to resolve story media', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolved.error?.status || 400 } + ) + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const containerBody: Record = { + media_type: 'STORIES', + } + if (resolved.media.kind === 'video') { + containerBody.video_url = resolved.media.url + } else { + containerBody.image_url = resolved.media.url + } + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish story failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish story'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-video/route.ts b/apps/sim/app/api/tools/instagram/publish-video/route.ts new file mode 100644 index 00000000000..4f4ca2cf224 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-video/route.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishVideoContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishVideoAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish video: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishVideoContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolvedVideo = await resolveInstagramMedia({ + input: body.video, + userId: authResult.userId, + requestId, + logger, + role: 'video', + label: 'Video', + }) + if (resolvedVideo.error || !resolvedVideo.media) { + return NextResponse.json( + { + success: false, + error: resolvedVideo.error?.message || 'Failed to resolve video', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedVideo.error?.status || 400 } + ) + } + + let coverUrl: string | undefined + if (body.cover != null && body.cover !== '') { + const resolvedCover = await resolveInstagramMedia({ + input: body.cover, + userId: authResult.userId, + requestId, + logger, + role: 'cover', + required: false, + label: 'Cover image', + }) + if (resolvedCover.error) { + return NextResponse.json( + { + success: false, + error: resolvedCover.error.message, + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedCover.error.status } + ) + } + coverUrl = resolvedCover.media?.url + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + // Meta deprecated media_type=VIDEO for standalone posts; feed videos are REELS + share_to_feed. + const containerBody: Record = { + media_type: 'REELS', + video_url: resolvedVideo.media.url, + share_to_feed: true, + } + if (body.caption) containerBody.caption = body.caption + if (coverUrl) containerBody.cover_url = coverUrl + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish video failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish video'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 205db37466c..4b0368813d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -19,6 +19,7 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { + useCloudStorageConfigured, useUploadWorkspaceFile, useWorkspaceFiles, workspaceFilesKeys, @@ -36,6 +37,11 @@ interface FileUploadProps { maxSize?: number // in MB acceptedTypes?: string // comma separated MIME types multiple?: boolean // whether to allow multiple file uploads + /** + * When true, disable new uploads and show a notice if S3/Blob is not configured + * (providers that need a public HTTPS URL Meta can fetch, e.g. Instagram). + */ + requiresCloudStorage?: boolean isPreview?: boolean previewValue?: any | null disabled?: boolean @@ -170,6 +176,7 @@ export function FileUpload({ maxSize = 10, // Default 10MB acceptedTypes = '*', multiple = false, // Default to single file for backward compatibility + requiresCloudStorage = false, isPreview = false, previewValue, disabled = false, @@ -212,6 +219,15 @@ export function FileUpload({ refetch: refetchWorkspaceFiles, } = useWorkspaceFiles(isPreview ? '' : workspaceId) + const { data: cloudConfigured, isLoading: loadingCloudStatus } = useCloudStorageConfigured( + requiresCloudStorage && !isPreview + ) + // Fail closed: block until the status check succeeds with true. Loading, errors, and + // explicit false all leave cloudConfigured !== true (avoid Meta-unfetchable files). + const cloudUploadBlocked = requiresCloudStorage && cloudConfigured !== true + const showCloudStorageWarning = + requiresCloudStorage && !loadingCloudStatus && cloudConfigured !== true + const uploadFileMutation = useUploadWorkspaceFile() const queryClient = useQueryClient() @@ -278,7 +294,7 @@ export function FileUpload({ e.preventDefault() e.stopPropagation() - if (disabled) return + if (disabled || cloudUploadBlocked) return if (fileInputRef.current) { fileInputRef.current.value = '' @@ -308,7 +324,7 @@ export function FileUpload({ * Handles file upload when new file(s) are selected */ const handleFileChange = async (e: React.ChangeEvent) => { - if (isPreview || disabled) return + if (isPreview || disabled || cloudUploadBlocked) return e.stopPropagation() @@ -459,6 +475,8 @@ export function FileUpload({ * Handle selecting an existing workspace file */ const handleSelectWorkspaceFile = (fileId: string) => { + if (cloudUploadBlocked) return + const selectedFile = workspaceFiles.find((f) => f.id === fileId) if (!selectedFile) return @@ -601,35 +619,36 @@ export function FileUpload({ // Options for multiple file mode (filters out already selected files) const comboboxOptions = useMemo( () => [ - { label: 'Upload New File', value: '__upload_new__' }, + { label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked }, ...availableWorkspaceFiles.map((file) => { const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) return { label: file.name, value: file.id, - disabled: !isAccepted, + // When cloud is required, local workspace files are also unpublishable. + disabled: !isAccepted || cloudUploadBlocked, } }), ], - [availableWorkspaceFiles, acceptedTypes] + [availableWorkspaceFiles, acceptedTypes, cloudUploadBlocked] ) // Options for single file mode (includes all files, selected one will be highlighted) const singleFileOptions = useMemo( () => [ - { label: 'Upload New File', value: '__upload_new__' }, + { label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked }, ...workspaceFiles.map((file) => { const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) return { label: file.name, value: file.id, - disabled: !isAccepted, + disabled: !isAccepted || cloudUploadBlocked, } }), ], - [workspaceFiles, acceptedTypes] + [workspaceFiles, acceptedTypes, cloudUploadBlocked] ) // Find the selected file's workspace ID for highlighting in single file mode @@ -667,6 +686,7 @@ export function FileUpload({ setInputValue('') if (value === '__upload_new__') { + if (cloudUploadBlocked) return handleOpenFileDialog({ preventDefault: () => {}, stopPropagation: () => {}, @@ -688,6 +708,13 @@ export function FileUpload({ data-testid='file-input-element' /> + {showCloudStorageWarning && ( +
+ Cloud storage (S3 or Blob) is required for file uploads. Configure S3_BUCKET_NAME and + AWS_REGION, or Azure Blob env vars. +
+ )} + {/* Error message */} {uploadError &&
{uploadError}
} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 7ed1f9806f6..e8752f0c4aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -906,6 +906,7 @@ function SubBlockComponent({ acceptedTypes={config.acceptedTypes || '*'} multiple={config.multiple === true} maxSize={config.maxSize} + requiresCloudStorage={config.requiresCloudStorage === true} isPreview={isPreview} previewValue={previewValue as any} disabled={isDisabled} diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts new file mode 100644 index 00000000000..54e3ffce4f9 --- /dev/null +++ b/apps/sim/blocks/blocks/instagram.ts @@ -0,0 +1,896 @@ +import { InstagramIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' +import type { InstagramResponse } from '@/tools/instagram/types' + +/** + * Resolves a canonical media input to either an uploaded file object or a plain URL string. + * `normalizeFileInput` only recognizes file objects (or JSON-serialized file references) — a raw + * HTTPS URL typed into the advanced field is passed through as a string. + */ +function resolveSingleMediaInput(value: unknown): object | string | undefined { + const file = normalizeFileInput(value, { single: true }) + if (file) return file + if (typeof value === 'string' && value.trim() !== '') return value.trim() + return undefined +} + +/** + * Resolves carousel media to a file array, or a legacy comma-separated URL string. + */ +function resolveCarouselMediaInput(value: unknown): object[] | string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return undefined + try { + const parsed = JSON.parse(trimmed) as unknown + const files = normalizeFileInput(parsed) + if (files) return files + } catch { + // plain URL or comma-separated URLs + } + return trimmed + } + const files = normalizeFileInput(value) + if (files) return files + return undefined +} + +const IG_USER_ID_OPS = [ + 'instagram_list_media', + 'instagram_list_stories', + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_publishing_limit', + 'instagram_private_reply', + 'instagram_list_conversations', + 'instagram_send_text_message', + 'instagram_get_account_insights', +] + +export const InstagramBlock: BlockConfig = { + type: 'instagram', + name: 'Instagram', + description: 'Publish content, moderate comments, and manage Instagram DMs', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate Instagram into workflows. Publish images, videos, Reels, stories, and carousels; moderate comments; send DMs; and pull account or media insights.', + docsLink: 'https://docs.sim.ai/integrations/instagram', + category: 'tools', + integrationType: IntegrationType.Marketing, + // Gradient fills the tile; white camera glyph sits on top (same pattern as AWS icons). + bgColor: 'radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)', + iconColor: '#E4405F', + icon: InstagramIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Get Profile', id: 'instagram_get_profile' }, + { label: 'List Media', id: 'instagram_list_media' }, + { label: 'Get Media', id: 'instagram_get_media' }, + { label: 'List Stories', id: 'instagram_list_stories' }, + { label: 'Publish Image', id: 'instagram_publish_image' }, + { label: 'Publish Video', id: 'instagram_publish_video' }, + { label: 'Publish Reel', id: 'instagram_publish_reel' }, + { label: 'Publish Story', id: 'instagram_publish_story' }, + { label: 'Publish Carousel', id: 'instagram_publish_carousel' }, + { label: 'Get Container Status', id: 'instagram_get_container_status' }, + { label: 'Get Publishing Limit', id: 'instagram_get_publishing_limit' }, + { label: 'List Comments', id: 'instagram_list_comments' }, + { label: 'Reply to Comment', id: 'instagram_reply_to_comment' }, + { label: 'Hide Comment', id: 'instagram_hide_comment' }, + { label: 'Delete Comment', id: 'instagram_delete_comment' }, + { label: 'Set Comments Enabled', id: 'instagram_set_comments_enabled' }, + { label: 'Private Reply', id: 'instagram_private_reply' }, + { label: 'List Conversations', id: 'instagram_list_conversations' }, + { label: 'Get Conversation Messages', id: 'instagram_get_conversation_messages' }, + { label: 'Get Message', id: 'instagram_get_message' }, + { label: 'Send Text Message', id: 'instagram_send_text_message' }, + { label: 'Get Account Insights', id: 'instagram_get_account_insights' }, + { label: 'Get Media Insights', id: 'instagram_get_media_insights' }, + ], + value: () => 'instagram_get_profile', + }, + + { + id: 'credential', + title: 'Instagram Account', + type: 'oauth-input', + serviceId: 'instagram', + canonicalParamId: 'oauthCredential', + mode: 'basic', + requiredScopes: getScopesForService('instagram'), + placeholder: 'Select Instagram account', + required: true, + }, + { + id: 'manualCredential', + title: 'Instagram Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + + // Publish Image — JPEG upload / file ref (same basic+advanced pattern as Gmail) + { + id: 'imageUpload', + title: 'Image', + type: 'file-upload', + canonicalParamId: 'image', + placeholder: 'Upload a JPEG image to publish', + acceptedTypes: '.jpg,.jpeg,image/jpeg', + maxSize: 8, + requiresCloudStorage: true, + condition: { field: 'operation', value: 'instagram_publish_image' }, + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'imageRef', + title: 'Image', + type: 'short-input', + canonicalParamId: 'image', + placeholder: 'Reference files from previous blocks', + condition: { field: 'operation', value: 'instagram_publish_image' }, + mode: 'advanced', + required: { field: 'operation', value: 'instagram_publish_image' }, + }, + + // Publish Video / Reel — video + optional cover + { + id: 'videoUpload', + title: 'Video', + type: 'file-upload', + canonicalParamId: 'video', + placeholder: 'Upload a video to publish', + acceptedTypes: '.mp4,.mov,video/mp4,video/quicktime', + maxSize: 300, + requiresCloudStorage: true, + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'basic', + multiple: false, + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'videoRef', + title: 'Video', + type: 'short-input', + canonicalParamId: 'video', + placeholder: 'Reference files from previous blocks', + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'advanced', + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'coverUpload', + title: 'Cover Image', + type: 'file-upload', + canonicalParamId: 'cover', + placeholder: 'Upload a JPEG cover image', + acceptedTypes: '.jpg,.jpeg,image/jpeg', + maxSize: 8, + requiresCloudStorage: true, + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'basic', + multiple: false, + required: false, + }, + { + id: 'coverRef', + title: 'Cover Image', + type: 'short-input', + canonicalParamId: 'cover', + placeholder: 'Reference files from previous blocks', + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'advanced', + required: false, + }, + + // Publish Story — single image or video + { + id: 'storyMediaUpload', + title: 'Media', + type: 'file-upload', + canonicalParamId: 'media', + placeholder: 'Upload a JPEG image or MP4/MOV video for the story', + acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', + maxSize: 100, + requiresCloudStorage: true, + condition: { field: 'operation', value: 'instagram_publish_story' }, + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'instagram_publish_story' }, + }, + { + id: 'storyMediaRef', + title: 'Media', + type: 'short-input', + canonicalParamId: 'media', + placeholder: 'Reference files from previous blocks', + condition: { field: 'operation', value: 'instagram_publish_story' }, + mode: 'advanced', + required: { field: 'operation', value: 'instagram_publish_story' }, + }, + + // Publish Carousel — up to 10 items + { + id: 'carouselMediaUpload', + title: 'Media', + type: 'file-upload', + canonicalParamId: 'carouselMedia', + placeholder: 'Upload 2-10 images/videos to publish', + acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', + maxSize: 300, + requiresCloudStorage: true, + condition: { field: 'operation', value: 'instagram_publish_carousel' }, + mode: 'basic', + multiple: true, + required: { field: 'operation', value: 'instagram_publish_carousel' }, + }, + { + id: 'carouselMediaRef', + title: 'Media', + type: 'long-input', + canonicalParamId: 'carouselMedia', + placeholder: 'Reference files from previous blocks', + condition: { field: 'operation', value: 'instagram_publish_carousel' }, + mode: 'advanced', + required: { field: 'operation', value: 'instagram_publish_carousel' }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of public HTTPS media URLs for an Instagram carousel. +Use plain image URLs for photos. Prefix video URLs with "video:" (e.g. video:https://example.com/clip.mp4). +Between 2 and 10 items. Do not invent unreachable URLs — only use URLs the user provided or clearly implied. +Examples: +- "two photos" with urls A and B -> https://cdn.example/a.jpg,https://cdn.example/b.jpg +- "photo then video" -> https://cdn.example/a.jpg,video:https://cdn.example/b.mp4 + +Return ONLY the comma-separated URLs - no explanations, no extra text.`, + placeholder: 'Describe the carousel media URLs to include...', + }, + }, + + { + id: 'caption', + title: 'Caption', + type: 'long-input', + placeholder: 'Write a caption...', + condition: { + field: 'operation', + value: [ + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_carousel', + ], + }, + }, + { + id: 'altText', + title: 'Alt Text', + type: 'short-input', + placeholder: 'Accessibility description for the image', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'isAiGenerated', + title: 'AI Generated', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'shareToFeed', + title: 'Share to Feed', + type: 'dropdown', + options: [ + // Yes first: Meta shares reels to the feed by default when the param is omitted. + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_reel' }, + }, + { + id: 'thumbOffset', + title: 'Thumbnail Offset (ms)', + type: 'short-input', + placeholder: 'Video frame offset in milliseconds for the cover thumbnail', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_reel' }, + }, + + { + id: 'mediaId', + title: 'Media ID', + type: 'short-input', + placeholder: 'Enter Instagram media ID', + condition: { + field: 'operation', + value: [ + 'instagram_get_media', + 'instagram_list_comments', + 'instagram_set_comments_enabled', + 'instagram_get_media_insights', + ], + }, + required: { + field: 'operation', + value: [ + 'instagram_get_media', + 'instagram_list_comments', + 'instagram_set_comments_enabled', + 'instagram_get_media_insights', + ], + }, + }, + { + id: 'containerId', + title: 'Container ID', + type: 'short-input', + placeholder: 'Enter media container ID', + condition: { field: 'operation', value: 'instagram_get_container_status' }, + required: { field: 'operation', value: 'instagram_get_container_status' }, + }, + { + id: 'commentId', + title: 'Comment ID', + type: 'short-input', + placeholder: 'Enter comment ID', + condition: { + field: 'operation', + value: [ + 'instagram_reply_to_comment', + 'instagram_hide_comment', + 'instagram_delete_comment', + 'instagram_private_reply', + ], + }, + required: { + field: 'operation', + value: [ + 'instagram_reply_to_comment', + 'instagram_hide_comment', + 'instagram_delete_comment', + 'instagram_private_reply', + ], + }, + }, + { + id: 'message', + title: 'Message', + type: 'long-input', + placeholder: 'Enter message text', + condition: { + field: 'operation', + value: [ + 'instagram_reply_to_comment', + 'instagram_private_reply', + 'instagram_send_text_message', + ], + }, + required: { + field: 'operation', + value: [ + 'instagram_reply_to_comment', + 'instagram_private_reply', + 'instagram_send_text_message', + ], + }, + }, + { + id: 'hide', + title: 'Hide Comment', + type: 'dropdown', + options: [ + { label: 'Hide', id: 'true' }, + { label: 'Unhide', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'instagram_hide_comment' }, + required: { field: 'operation', value: 'instagram_hide_comment' }, + }, + { + id: 'commentEnabled', + title: 'Comments Enabled', + type: 'dropdown', + options: [ + { label: 'Enable', id: 'true' }, + { label: 'Disable', id: 'false' }, + ], + value: () => 'true', + condition: { field: 'operation', value: 'instagram_set_comments_enabled' }, + required: { field: 'operation', value: 'instagram_set_comments_enabled' }, + }, + { + id: 'conversationId', + title: 'Conversation ID', + type: 'short-input', + placeholder: 'Enter conversation ID', + condition: { field: 'operation', value: 'instagram_get_conversation_messages' }, + required: { field: 'operation', value: 'instagram_get_conversation_messages' }, + }, + { + id: 'messageId', + title: 'Message ID', + type: 'short-input', + placeholder: 'Enter message ID', + condition: { field: 'operation', value: 'instagram_get_message' }, + required: { field: 'operation', value: 'instagram_get_message' }, + }, + { + id: 'recipientId', + title: 'Recipient ID', + type: 'short-input', + placeholder: 'Instagram-scoped user ID', + condition: { field: 'operation', value: 'instagram_send_text_message' }, + required: { field: 'operation', value: 'instagram_send_text_message' }, + }, + { + id: 'metrics', + title: 'Metrics', + type: 'short-input', + placeholder: 'Comma-separated metrics (e.g. reach,views,likes)', + condition: { + field: 'operation', + value: ['instagram_get_account_insights', 'instagram_get_media_insights'], + }, + required: { + field: 'operation', + value: ['instagram_get_account_insights', 'instagram_get_media_insights'], + }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of Instagram Insights metric names based on the user's request. +Account insights examples: reach,views,accounts_engaged,likes,comments,saves,shares,total_interactions +Media insights examples: views,reach,likes,comments,saved,shares,total_interactions +Use only valid Instagram Graph metric names. Prefer the smallest useful set. + +Return ONLY the comma-separated metric names - no explanations, no extra text.`, + placeholder: 'Describe which insight metrics you need...', + }, + }, + { + id: 'period', + title: 'Period', + type: 'dropdown', + options: [ + { label: 'Day', id: 'day' }, + { label: 'Week', id: 'week' }, + { label: '28 Days', id: 'days_28' }, + { label: 'Lifetime', id: 'lifetime' }, + { label: 'Total Over Range', id: 'total_over_range' }, + ], + value: () => 'day', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + required: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + { + id: 'since', + title: 'Since', + type: 'short-input', + placeholder: 'Unix timestamp or YYYY-MM-DD', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: `Generate a Unix timestamp in seconds (or YYYY-MM-DD) for the start of an Instagram insights range based on the user's description. +Examples: +- "7 days ago" -> Unix timestamp for 7 days ago at 00:00:00 UTC +- "start of last month" -> Unix timestamp for the first day of last month + +Return ONLY the timestamp or date - no explanations, no extra text.`, + placeholder: 'Describe the range start (e.g. "7 days ago")...', + }, + }, + { + id: 'until', + title: 'Until', + type: 'short-input', + placeholder: 'Unix timestamp or YYYY-MM-DD', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: `Generate a Unix timestamp in seconds (or YYYY-MM-DD) for the end of an Instagram insights range based on the user's description. +Examples: +- "now" -> current Unix timestamp +- "end of yesterday" -> Unix timestamp for yesterday 23:59:59 UTC + +Return ONLY the timestamp or date - no explanations, no extra text.`, + placeholder: 'Describe the range end (e.g. "now")...', + }, + }, + { + id: 'metricType', + title: 'Metric Type', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Time Series', id: 'time_series' }, + { label: 'Total Value', id: 'total_value' }, + ], + value: () => '', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + { + id: 'breakdown', + title: 'Breakdown', + type: 'short-input', + placeholder: 'Optional breakdown dimension', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + { + id: 'timeframe', + title: 'Demographic Timeframe', + type: 'dropdown', + options: [ + // Empty default keeps timeframe out of the request; it is only valid + // for demographic metrics (follower_demographics, engaged_audience_demographics). + { label: 'Default', id: '' }, + { label: 'This Week', id: 'this_week' }, + { label: 'This Month', id: 'this_month' }, + ], + value: () => '', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: 'Max results (1-100, default 25)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['instagram_list_media', 'instagram_list_comments', 'instagram_list_conversations'], + }, + }, + { + id: 'after', + title: 'After Cursor', + type: 'short-input', + placeholder: 'Pagination cursor from a previous response', + mode: 'advanced', + condition: { + field: 'operation', + value: ['instagram_list_media', 'instagram_list_comments', 'instagram_list_conversations'], + }, + }, + { + id: 'igUserId', + title: 'Instagram User ID', + type: 'short-input', + placeholder: 'Optional IG professional account user id', + mode: 'advanced', + condition: { field: 'operation', value: IG_USER_ID_OPS }, + }, + ], + tools: { + access: [ + 'instagram_get_profile', + 'instagram_list_media', + 'instagram_get_media', + 'instagram_list_stories', + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_container_status', + 'instagram_get_publishing_limit', + 'instagram_list_comments', + 'instagram_reply_to_comment', + 'instagram_hide_comment', + 'instagram_delete_comment', + 'instagram_set_comments_enabled', + 'instagram_private_reply', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + 'instagram_get_message', + 'instagram_send_text_message', + 'instagram_get_account_insights', + 'instagram_get_media_insights', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const { oauthCredential, image, video, cover, media, carouselMedia, ...rest } = params + const result: Record = { + credential: oauthCredential, + } + + const operation = params.operation as string | undefined + + if (operation === 'instagram_publish_image') { + const resolved = resolveSingleMediaInput(image) + if (resolved) result.image = resolved + } else if ( + operation === 'instagram_publish_video' || + operation === 'instagram_publish_reel' + ) { + const resolvedVideo = resolveSingleMediaInput(video) + if (resolvedVideo) result.video = resolvedVideo + const resolvedCover = resolveSingleMediaInput(cover) + if (resolvedCover) result.cover = resolvedCover + } else if (operation === 'instagram_publish_story') { + const resolved = resolveSingleMediaInput(media) + if (resolved) result.media = resolved + } else if (operation === 'instagram_publish_carousel') { + // Distinct UI canonical (carouselMedia) so it doesn't collide with story's media pair + const resolved = resolveCarouselMediaInput(carouselMedia) + if (resolved) result.media = resolved + } + + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + if (key === 'operation' || key === 'credential' || key === 'manualCredential') continue + // Skip raw upload/ref subBlock ids — only canonical media params above + if ( + key === 'imageUpload' || + key === 'imageRef' || + key === 'videoUpload' || + key === 'videoRef' || + key === 'coverUpload' || + key === 'coverRef' || + key === 'storyMediaUpload' || + key === 'storyMediaRef' || + key === 'carouselMediaUpload' || + key === 'carouselMediaRef' + ) { + continue + } + + if (key === 'limit' || key === 'thumbOffset') { + result[key] = Number(value) + } else if ( + key === 'hide' || + key === 'commentEnabled' || + key === 'shareToFeed' || + key === 'isAiGenerated' + ) { + result[key] = value === true || value === 'true' + } else { + result[key] = value + } + } + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + oauthCredential: { type: 'string', description: 'Instagram OAuth credential' }, + image: { type: 'json', description: 'JPEG image file or public HTTPS URL for Publish Image' }, + video: { + type: 'json', + description: 'Video file or public HTTPS URL for Publish Video / Publish Reel', + }, + cover: { + type: 'json', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + media: { + type: 'json', + description: 'Story media: single JPEG image or MP4/MOV video file, or a public HTTPS URL', + }, + carouselMedia: { + type: 'json', + description: + 'Carousel media: 2-10 files, or comma-separated public HTTPS URLs (prefix videos with video:)', + }, + caption: { type: 'string', description: 'Post caption' }, + altText: { type: 'string', description: 'Image accessibility alt text' }, + isAiGenerated: { type: 'boolean', description: 'Whether the image is AI-generated' }, + shareToFeed: { type: 'boolean', description: 'Also share Reel to the main feed' }, + thumbOffset: { + type: 'number', + description: 'Video frame offset in milliseconds for the Reel cover thumbnail', + }, + mediaId: { type: 'string', description: 'Instagram media ID' }, + containerId: { type: 'string', description: 'Media container ID' }, + commentId: { type: 'string', description: 'Comment ID' }, + message: { type: 'string', description: 'Message or reply text' }, + hide: { type: 'boolean', description: 'Hide or unhide a comment' }, + commentEnabled: { type: 'boolean', description: 'Enable or disable comments on media' }, + conversationId: { type: 'string', description: 'DM conversation ID' }, + messageId: { type: 'string', description: 'DM message ID' }, + recipientId: { type: 'string', description: 'DM recipient Instagram-scoped ID' }, + metrics: { type: 'string', description: 'Comma-separated insight metrics' }, + period: { type: 'string', description: 'Insight aggregation period' }, + since: { type: 'string', description: 'Account insights range start' }, + until: { type: 'string', description: 'Account insights range end' }, + metricType: { type: 'string', description: 'Account insights metric_type' }, + breakdown: { type: 'string', description: 'Account insights breakdown dimension' }, + timeframe: { + type: 'string', + description: 'Lookback window required for demographic insight metrics', + }, + limit: { type: 'number', description: 'Maximum number of results' }, + after: { type: 'string', description: 'Pagination cursor' }, + igUserId: { type: 'string', description: 'Instagram professional account user ID' }, + }, + outputs: { + // Profile + userId: { type: 'string', description: 'Instagram professional account user_id' }, + id: { type: 'string', description: 'Graph object or message id' }, + username: { type: 'string', description: 'Instagram username' }, + name: { type: 'string', description: 'Display name' }, + accountType: { type: 'string', description: 'Business or Media_Creator' }, + profilePictureUrl: { type: 'string', description: 'Profile picture URL' }, + followersCount: { type: 'number', description: 'Follower count' }, + followsCount: { type: 'number', description: 'Following count' }, + mediaCount: { type: 'number', description: 'Media count' }, + // Media + media: { + type: 'json', + description: + 'List of media objects (id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount)', + }, + caption: { type: 'string', description: 'Media caption text' }, + mediaType: { type: 'string', description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM' }, + mediaProductType: { type: 'string', description: 'Feed, Reels, or Stories product type' }, + mediaUrl: { type: 'string', description: 'CDN media URL when available' }, + permalink: { type: 'string', description: 'Permalink to the post' }, + timestamp: { type: 'string', description: 'ISO timestamp' }, + likeCount: { type: 'number', description: 'Like count' }, + commentsCount: { type: 'number', description: 'Comments count' }, + children: { type: 'json', description: 'Carousel child media ids' }, + stories: { + type: 'json', + description: 'Active stories (id, mediaType, mediaUrl, timestamp)', + }, + // Publishing + containerId: { type: 'string', description: 'Media container id' }, + mediaId: { type: 'string', description: 'Published media id' }, + statusCode: { + type: 'string', + description: 'Container status (EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED)', + }, + status: { type: 'string', description: 'Detailed container status message' }, + quotaUsage: { type: 'number', description: 'Publishes used in the current window' }, + config: { type: 'json', description: 'Publishing quota config (quotaTotal, quotaDuration)' }, + // Comments + comments: { + type: 'json', + description: 'Comments (id, text, username, timestamp, likeCount, hidden)', + }, + success: { type: 'boolean', description: 'Whether the operation succeeded' }, + // Messaging + conversations: { type: 'json', description: 'Conversations (id, updatedTime)' }, + conversationId: { type: 'string', description: 'Conversation id' }, + messages: { + type: 'json', + description: + 'Message references (id, createdTime); use Get Message for sender, recipient, and text', + }, + createdTime: { type: 'string', description: 'Message created timestamp' }, + fromId: { type: 'string', description: 'Sender Instagram-scoped id' }, + fromUsername: { type: 'string', description: 'Sender username' }, + toId: { type: 'string', description: 'Recipient id' }, + message: { type: 'string', description: 'Message text' }, + messageId: { type: 'string', description: 'Sent or retrieved message id' }, + recipientId: { type: 'string', description: 'DM recipient id' }, + // Insights & pagination + insights: { + type: 'json', + description: 'Insight metrics (name, period, title, description, values, totalValue)', + }, + nextCursor: { type: 'string', description: 'Pagination cursor for the next page' }, + error: { type: 'string', description: 'Error message if the operation failed' }, + }, +} + +export const InstagramBlockMeta = { + tags: ['marketing', 'messaging', 'automation'], + url: 'https://www.instagram.com', + templates: [ + { + icon: InstagramIcon, + title: 'Instagram content publisher', + prompt: + 'Build a workflow that reads approved posts from a content table, publishes each as an Instagram image or Reel with caption, checks container status until finished, and writes the published media ID back to the row.', + modules: ['tables', 'scheduled', 'workflows'], + category: 'marketing', + tags: ['marketing', 'content', 'automation'], + }, + { + icon: InstagramIcon, + title: 'Instagram comment moderator', + prompt: + 'Create a scheduled workflow that lists recent Instagram media, pulls comments on each post, uses an agent to flag spam or abusive replies, and hides or deletes those comments while logging actions to a moderation table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation', 'moderation'], + }, + { + icon: InstagramIcon, + title: 'Instagram DM reply assistant', + prompt: + 'Build a workflow that lists Instagram Direct conversations, fetches recent message references and details, drafts helpful replies with an agent, and sends text messages back to the recipient while logging the thread to a support table.', + modules: ['tables', 'agent', 'workflows'], + category: 'support', + tags: ['messaging', 'support', 'automation'], + }, + { + icon: InstagramIcon, + title: 'Instagram insights digest', + prompt: + 'Create a scheduled weekly workflow that fetches Instagram account insights and media insights for top posts, summarizes performance with an agent, and writes a digest to a marketing table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'analytics', 'automation'], + }, + ], + skills: [ + { + name: 'publish-instagram-image', + description: + 'Publish a JPEG image to Instagram with an optional caption and accessibility alt text.', + content: + '# Publish Instagram Image\n\nPublish a single image post to the connected Instagram professional account.\n\n## Steps\n1. Upload a JPEG image (or reference a file from a previous block / paste a public HTTPS JPEG URL) and draft a caption within Instagram length limits.\n2. Optionally set alt text for accessibility and mark the post as AI-generated when applicable.\n3. Run Publish Image, then optionally Get Container Status if you need to confirm publishing finished.\n\n## Output\nThe published media ID, container ID, status, and the final caption used.', + }, + { + name: 'moderate-instagram-comments', + description: + 'List comments on a post and hide, delete, or reply to ones that need moderation.', + content: + '# Moderate Instagram Comments\n\nReview and act on comments for a specific Instagram media object.\n\n## Steps\n1. List Comments for the target media ID and review text, username, and hidden state.\n2. Hide Comment or Delete Comment for spam or abusive replies; Reply to Comment for public responses; Private Reply when a DM follow-up is better.\n3. Optionally Set Comments Enabled to turn commenting off on the post.\n\n## Output\nA short summary of actions taken (hidden, deleted, replied) with comment IDs.', + }, + { + name: 'reply-instagram-dm', + description: 'Open an Instagram Direct conversation and send a text reply to the recipient.', + content: + '# Reply Instagram DM\n\nRespond to an Instagram Direct message thread.\n\n## Steps\n1. List Conversations to find the thread, then Get Conversation Messages for recent context.\n2. Optionally Get Message for a specific message ID if you need full details.\n3. Send Text Message to the recipient ID with a clear, helpful reply.\n\n## Output\nThe sent message ID, recipient ID, and the reply text used.', + }, + { + name: 'fetch-instagram-insights', + description: + 'Pull account-level or media-level Instagram insights for reporting and analysis.', + content: + '# Fetch Instagram Insights\n\nRetrieve performance metrics for the account or a specific post.\n\n## Steps\n1. For account interaction trends, run Get Account Insights with comma-separated metrics and the day period. Use lifetime plus a timeframe for demographic metrics.\n2. For a specific post, run Get Media Insights with the media ID and metrics like views, reach, likes, comments, saved, or shares.\n3. Summarize the returned insight values for the reporting window.\n\n## Output\nThe insights JSON plus a short plain-language summary of the key metrics.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 7934e50a8f1..d8d5e422cf4 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -143,6 +143,7 @@ import { ImapBlock, ImapBlockMeta } from '@/blocks/blocks/imap' import { IncidentioBlock, IncidentioBlockMeta } from '@/blocks/blocks/incidentio' import { InfisicalBlock, InfisicalBlockMeta } from '@/blocks/blocks/infisical' import { InputTriggerBlock } from '@/blocks/blocks/input_trigger' +import { InstagramBlock, InstagramBlockMeta } from '@/blocks/blocks/instagram' import { InstantlyBlock, InstantlyBlockMeta } from '@/blocks/blocks/instantly' import { IntercomBlock, @@ -473,6 +474,7 @@ export const BLOCK_REGISTRY: Record = { incidentio: IncidentioBlock, infisical: InfisicalBlock, input_trigger: InputTriggerBlock, + instagram: InstagramBlock, instantly: InstantlyBlock, intercom: IntercomBlock, intercom_v2: IntercomV2Block, @@ -768,6 +770,7 @@ export const BLOCK_META_REGISTRY: Record = { imap: ImapBlockMeta, incidentio: IncidentioBlockMeta, infisical: InfisicalBlockMeta, + instagram: InstagramBlockMeta, instantly: InstantlyBlockMeta, intercom: IntercomBlockMeta, intercom_v2: IntercomV2BlockMeta, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index d063f43d38a..9b6653c3283 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -381,6 +381,11 @@ export interface SubBlockConfig { acceptedTypes?: string multiple?: boolean maxSize?: number + /** + * When true, FileUpload checks for S3/Blob and warns / disables new uploads if missing. + * Used by providers (e.g. Instagram) that need a Meta-fetchable public HTTPS URL. + */ + requiresCloudStorage?: boolean // Slider-specific properties step?: number integer?: boolean diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index f1fb7446eeb..4aa8edcd482 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1467,6 +1467,21 @@ export function LinkedInIcon(props: SVGProps) { ) } +/** Placeholder Instagram glyph — replace with brand SVG from press kit when provided. */ +export function InstagramIcon(props: SVGProps) { + return ( + + ) +} + export function CrunchbaseIcon(props: SVGProps) { return ( [...workspaceFilesKeys.all, 'storageInfo'] as const, + cloudConfigured: () => [...workspaceFilesKeys.all, 'cloudConfigured'] as const, } export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000 export const WORKSPACE_STORAGE_INFO_STALE_TIME = 60 * 1000 +/** Cloud storage (S3/Blob) is env-driven and does not change at runtime. */ +export const CLOUD_STORAGE_CONFIGURED_STALE_TIME = Number.POSITIVE_INFINITY /** * Storage info type @@ -294,6 +298,25 @@ export function useStorageInfo(enabled = true) { }) } +async function fetchCloudStorageConfigured(signal?: AbortSignal): Promise { + const data = await requestJson(fileStorageStatusContract, { signal }) + return data.cloudConfigured === true +} + +/** + * Whether S3 or Azure Blob is configured. Used by file uploads that need a + * publicly fetchable HTTPS URL (e.g. Instagram publish). + */ +export function useCloudStorageConfigured(enabled = true) { + return useQuery({ + queryKey: workspaceFilesKeys.cloudConfigured(), + queryFn: ({ signal }) => fetchCloudStorageConfigured(signal), + enabled, + retry: false, + staleTime: CLOUD_STORAGE_CONFIGURED_STALE_TIME, + }) +} + /** * Upload workspace file mutation */ diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index f451f555c1f..8c110313007 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -191,6 +191,33 @@ export const trelloCallbackContract = defineRouteContract({ response: { mode: 'text' }, }) +export const instagramAuthorizeQuerySchema = z.object({ + returnUrl: z.string().optional(), + workspaceId: workspaceIdSchema.optional(), +}) + +export const authorizeInstagramContract = defineRouteContract({ + method: 'GET', + path: '/api/auth/instagram/authorize', + query: instagramAuthorizeQuerySchema, + response: { mode: 'redirect' }, +}) + +export const instagramCallbackQuerySchema = z.object({ + code: z.string().optional(), + state: z.string().optional(), + error: z.string().optional(), + error_reason: z.string().optional(), + error_description: z.string().optional(), +}) + +export const instagramCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/auth/oauth2/callback/instagram', + query: instagramCallbackQuerySchema, + response: { mode: 'redirect' }, +}) + export const authorizeOAuth2QuerySchema = z.object({ providerId: z.string().min(1, 'providerId is required'), workspaceId: workspaceIdSchema, diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 4a421203e4a..7f49d9e797e 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -745,6 +745,18 @@ export const fileExportContract = defineRouteContract({ response: { mode: 'binary' }, }) +export const fileStorageStatusResponseSchema = z.object({ + cloudConfigured: z.boolean(), +}) + +export const fileStorageStatusContract = defineRouteContract({ + method: 'GET', + path: '/api/files/storage-status', + response: { mode: 'json', schema: fileStorageStatusResponseSchema }, +}) + +export type FileStorageStatusResponse = ContractJsonResponse + export type BoxUploadBody = ContractBodyInput export type BoxUploadResponse = ContractJsonResponse export type DropboxUploadBody = ContractBodyInput diff --git a/apps/sim/lib/api/contracts/tools/instagram.ts b/apps/sim/lib/api/contracts/tools/instagram.ts new file mode 100644 index 00000000000..876b2a6b27d --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/instagram.ts @@ -0,0 +1,121 @@ +import { z } from 'zod' +import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + FileInputSchema, + RawFileInputArraySchema, + RawFileInputSchema, +} from '@/lib/uploads/utils/file-schemas' + +export const instagramAccessTokenSchema = z.string().min(1, 'Access token is required') + +/** Single media: uploaded file object or public HTTPS URL string. */ +export const instagramMediaInputSchema = FileInputSchema + +/** + * Carousel media: file array, single file, or legacy comma-separated URL string + * (optional `video:` prefix per entry). + */ +export const instagramCarouselMediaSchema = z.union([ + RawFileInputArraySchema, + RawFileInputSchema, + z.string().min(1, 'Carousel media is required'), +]) + +export const instagramPublishImageBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: z.string().optional().nullable(), + image: instagramMediaInputSchema, + caption: z.string().optional().nullable(), + altText: z.string().optional().nullable(), + isAiGenerated: z.boolean().optional().nullable(), +}) + +export const instagramPublishVideoBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: z.string().optional().nullable(), + video: instagramMediaInputSchema, + cover: instagramMediaInputSchema.optional().nullable(), + caption: z.string().optional().nullable(), +}) + +export const instagramPublishReelBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: z.string().optional().nullable(), + video: instagramMediaInputSchema, + cover: instagramMediaInputSchema.optional().nullable(), + caption: z.string().optional().nullable(), + shareToFeed: z.boolean().optional().nullable(), + thumbOffset: z.number().optional().nullable(), +}) + +export const instagramPublishStoryBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: z.string().optional().nullable(), + media: instagramMediaInputSchema, +}) + +export const instagramPublishCarouselBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: z.string().optional().nullable(), + media: instagramCarouselMediaSchema, + caption: z.string().optional().nullable(), +}) + +export const instagramPublishImageContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-image', + body: instagramPublishImageBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishVideoContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-video', + body: instagramPublishVideoBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishReelContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-reel', + body: instagramPublishReelBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishStoryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-story', + body: instagramPublishStoryBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishCarouselContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-carousel', + body: instagramPublishCarouselBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export type InstagramPublishImageBody = ContractBodyInput +export type InstagramPublishVideoBody = ContractBodyInput +export type InstagramPublishReelBody = ContractBodyInput +export type InstagramPublishStoryBody = ContractBodyInput +export type InstagramPublishCarouselBody = ContractBodyInput< + typeof instagramPublishCarouselContract +> + +export type InstagramPublishImageResponse = ContractJsonResponse< + typeof instagramPublishImageContract +> +export type InstagramPublishVideoResponse = ContractJsonResponse< + typeof instagramPublishVideoContract +> +export type InstagramPublishReelResponse = ContractJsonResponse +export type InstagramPublishStoryResponse = ContractJsonResponse< + typeof instagramPublishStoryContract +> +export type InstagramPublishCarouselResponse = ContractJsonResponse< + typeof instagramPublishCarouselContract +> diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 3919b0ba557..2dcaabe02a7 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -115,6 +115,12 @@ async function generateOAuthLink( if (providerId === 'trello') { return { url: `${baseUrl}/api/auth/trello/authorize`, providerId, serviceName } } + if (providerId === 'instagram') { + const authorizeUrl = new URL(`${baseUrl}/api/auth/instagram/authorize`) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + authorizeUrl.searchParams.set('workspaceId', workspaceId) + return { url: authorizeUrl.toString(), providerId, serviceName } + } if (providerId === 'shopify') { const returnUrl = encodeURIComponent(callbackURL) return { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a7e56751caf..e315dd147c9 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -397,6 +397,8 @@ export const env = createEnv({ TRELLO_API_KEY: z.string().optional(), // Trello API Key LINKEDIN_CLIENT_ID: z.string().optional(), // LinkedIn OAuth client ID LINKEDIN_CLIENT_SECRET: z.string().optional(), // LinkedIn OAuth client secret + INSTAGRAM_CLIENT_ID: z.string().optional(), // Instagram App ID (Business Login) + INSTAGRAM_CLIENT_SECRET: z.string().optional(), // Instagram App Secret (Business Login) SHOPIFY_CLIENT_ID: z.string().optional(), // Shopify OAuth client ID SHOPIFY_CLIENT_SECRET: z.string().optional(), // Shopify OAuth client secret ZOOM_CLIENT_ID: z.string().optional(), // Zoom OAuth client ID diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts new file mode 100644 index 00000000000..1c6d5ff2d87 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -0,0 +1,63 @@ +import { db } from '@sim/db' +import { pendingCredentialDraft, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, lt } from 'drizzle-orm' +import { getAllOAuthServices } from '@/lib/oauth/utils' + +const logger = createLogger('OAuthConnectDraft') +const DRAFT_TTL_MS = 15 * 60 * 1000 + +/** + * Creates the pending credential draft at OAuth click time so custom and + * generic OAuth callbacks can materialize the connected workspace credential. + */ +export async function createConnectDraft(params: { + userId: string + workspaceId: string + providerId: string +}): Promise { + const { userId, workspaceId, providerId } = params + const service = getAllOAuthServices().find((candidate) => candidate.providerId === providerId) + + let displayName = service?.name ?? providerId + try { + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (row?.name) { + displayName = `${row.name}'s ${displayName}` + } + } catch { + // Fall back to the service name. + } + + const now = new Date() + const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + + await db + .delete(pendingCredentialDraft) + .where( + and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) + ) + + await db + .insert(pendingCredentialDraft) + .values({ + id: generateId(), + userId, + workspaceId, + providerId, + displayName, + expiresAt, + createdAt: now, + }) + .onConflictDoUpdate({ + target: [ + pendingCredentialDraft.userId, + pendingCredentialDraft.providerId, + pendingCredentialDraft.workspaceId, + ], + set: { displayName, expiresAt, createdAt: now }, + }) + + logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) +} diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index f7c5ddf8d19..e8e5fe44e7f 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -109,6 +109,7 @@ import { IdentityCenterIcon, IncidentioIcon, InfisicalIcon, + InstagramIcon, InstantlyIcon, IntercomIcon, JinaAIIcon, @@ -345,6 +346,7 @@ export const blockTypeToIconMap: Record = { imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, + instagram: InstagramIcon, instantly: InstantlyIcon, intercom_v2: IntercomIcon, jina: JinaAIIcon, diff --git a/apps/sim/lib/integrations/instagram.ts b/apps/sim/lib/integrations/instagram.ts new file mode 100644 index 00000000000..cc641f5584f --- /dev/null +++ b/apps/sim/lib/integrations/instagram.ts @@ -0,0 +1,2 @@ +export const INSTAGRAM_GRAPH_VERSION = 'v22.0' +export const INSTAGRAM_GRAPH_BASE = `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}` diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/lib/integrations/instagram/resolve-media.ts new file mode 100644 index 00000000000..732eed7fdaa --- /dev/null +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -0,0 +1,376 @@ +import type { Logger } from '@sim/logger' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { + getFileExtension, + getMimeTypeFromExtension, + isInternalFileUrl, + type RawFileInput, + resolveFileType, +} from '@/lib/uploads/utils/file-utils' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' + +/** Covers Meta's poll-once-per-minute for ≤5 minutes while the container processes. */ +export const INSTAGRAM_MEDIA_URL_TTL_SECONDS = 600 + +const IMAGE_MAX_BYTES = 8 * 1024 * 1024 +const REEL_VIDEO_MAX_BYTES = 300 * 1024 * 1024 +const STORY_VIDEO_MAX_BYTES = 100 * 1024 * 1024 + +const JPEG_MIME = new Set(['image/jpeg', 'image/jpg']) +const VIDEO_MIME = new Set(['video/mp4', 'video/quicktime']) +const JPEG_EXT = new Set(['jpg', 'jpeg']) +const VIDEO_EXT = new Set(['mp4', 'mov']) + +const CLOUD_STORAGE_REQUIRED_MESSAGE = + 'Cloud storage is required to publish uploaded Instagram media. Configure S3_BUCKET_NAME and AWS_REGION (or Azure Blob AZURE_STORAGE_* vars), or paste a public HTTPS URL instead.' + +export type InstagramMediaRole = 'image' | 'video' | 'cover' | 'story' | 'carousel' + +export interface ResolvedInstagramMedia { + url: string + kind: 'image' | 'video' + mimeType?: string + size?: number + name?: string +} + +export interface ResolveInstagramMediaResult { + media?: ResolvedInstagramMedia + error?: { status: number; message: string } +} + +export interface ResolveInstagramMediaOptions { + input: unknown + userId: string + requestId: string + logger: Logger + role: InstagramMediaRole + required?: boolean + label?: string +} + +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}MB` + return `${bytes} bytes` +} + +function extensionFromUrlOrName(value?: string): string { + if (!value) return '' + try { + const pathname = + value.startsWith('http://') || value.startsWith('https://') ? new URL(value).pathname : value + return getFileExtension(pathname.split('?')[0] || '') + } catch { + return getFileExtension(value) + } +} + +function inferKindFromMimeOrExt( + mimeType: string | undefined, + extension: string +): 'image' | 'video' | null { + const mime = (mimeType || '').toLowerCase() + if (mime.startsWith('image/') || JPEG_EXT.has(extension)) return 'image' + if (mime.startsWith('video/') || VIDEO_EXT.has(extension)) return 'video' + return null +} + +function validateMediaConstraints( + kind: 'image' | 'video', + role: InstagramMediaRole, + mimeType: string | undefined, + size: number | undefined, + label: string +): string | null { + const mime = (mimeType || '').toLowerCase() + + if (kind === 'image') { + if (mime && !JPEG_MIME.has(mime)) { + return `${label} must be a JPEG image (got ${mime})` + } + if (size != null && size > IMAGE_MAX_BYTES) { + return `${label} exceeds Instagram's ${formatBytes(IMAGE_MAX_BYTES)} JPEG limit (got ${formatBytes(size)})` + } + return null + } + + if (mime && !VIDEO_MIME.has(mime)) { + return `${label} must be an MP4 or MOV video (got ${mime})` + } + + const maxBytes = role === 'story' ? STORY_VIDEO_MAX_BYTES : REEL_VIDEO_MAX_BYTES + if (size != null && size > maxBytes) { + return `${label} exceeds Instagram's ${formatBytes(maxBytes)} video limit for ${role} (got ${formatBytes(size)})` + } + return null +} + +function isPublicHttpUrl(value: string): boolean { + return (value.startsWith('https://') || value.startsWith('http://')) && !isInternalFileUrl(value) +} + +function needsCloudStorage(file?: RawFileInput, filePath?: string): boolean { + if (file) return true + if (filePath && isInternalFileUrl(filePath)) return true + return false +} + +/** + * Split a canonical media input into the shapes {@link resolveFileInputToUrl} expects + * (Reducto/STT pattern: file object vs filePath string). + */ +function splitMediaInput(input: unknown): { + file?: RawFileInput + filePath?: string + name?: string + size?: number + mimeType?: string + error?: { status: number; message: string } +} { + if (typeof input === 'string') { + return { filePath: input.trim() } + } + + if (typeof input === 'object' && input !== null && !Array.isArray(input)) { + const raw = input as RawFileInput + if (!raw.name) { + return { error: { status: 400, message: 'File must include a name' } } + } + return { + file: raw, + name: raw.name, + size: typeof raw.size === 'number' ? raw.size : undefined, + mimeType: resolveFileType({ type: raw.type || '', name: raw.name }), + } + } + + return { error: { status: 400, message: 'Media must be a file or URL string' } } +} + +function applyInstagramConstraints( + url: string, + role: InstagramMediaRole, + label: string, + meta: { name?: string; size?: number; mimeType?: string } +): ResolveInstagramMediaResult { + const mimeType = + meta.mimeType || getMimeTypeFromExtension(extensionFromUrlOrName(meta.name || url)) + const extension = extensionFromUrlOrName(meta.name || url) + + let kind: 'image' | 'video' + if (role === 'image' || role === 'cover') { + kind = 'image' + } else if (role === 'video') { + kind = 'video' + } else { + const inferred = inferKindFromMimeOrExt(mimeType, extension) + if (!inferred) { + return { + error: { + status: 400, + message: `${label} must be a JPEG image or MP4/MOV video`, + }, + } + } + kind = inferred + } + + if ( + (role === 'story' || role === 'carousel') && + (!mimeType || mimeType === 'application/octet-stream') + ) { + if (JPEG_EXT.has(extension)) kind = 'image' + else if (VIDEO_EXT.has(extension)) kind = 'video' + } + + const constraintError = validateMediaConstraints( + kind, + role, + mimeType === 'application/octet-stream' ? undefined : mimeType, + meta.size, + label + ) + if (constraintError) { + return { error: { status: 400, message: constraintError } } + } + + if (kind === 'image' && extension && !JPEG_EXT.has(extension) && !mimeType) { + return { + error: { status: 400, message: `${label} must be a JPEG (.jpg/.jpeg)` }, + } + } + if (kind === 'video' && extension && !VIDEO_EXT.has(extension) && !mimeType) { + return { + error: { status: 400, message: `${label} must be an MP4 or MOV video` }, + } + } + + return { + media: { + url, + kind, + mimeType, + size: meta.size, + name: meta.name, + }, + } +} + +/** + * Resolve a UserFile, internal serve path, or public HTTPS URL into a Meta-fetchable HTTPS URL. + * Delegates UserFile serialization and URL minting to {@link resolveFileInputToUrl} + * (600s TTL, prefer key presign). Instagram-specific MIME/size checks stay here. + */ +export async function resolveInstagramMedia( + options: ResolveInstagramMediaOptions +): Promise { + const { input, userId, requestId, logger, role, required = true, label = 'Media' } = options + + if (input == null || input === '') { + if (required) { + return { error: { status: 400, message: `${label} is required` } } + } + return {} + } + + const split = splitMediaInput(input) + if (split.error) { + return { error: split.error } + } + + const { file, filePath, name, size, mimeType } = split + if (filePath !== undefined && filePath === '') { + if (required) { + return { error: { status: 400, message: `${label} is required` } } + } + return {} + } + + if (needsCloudStorage(file, filePath) && !hasCloudStorage()) { + return { error: { status: 400, message: CLOUD_STORAGE_REQUIRED_MESSAGE } } + } + + // Meta only curls HTTPS; reject plain HTTP before the shared helper accepts it. + if (filePath && isPublicHttpUrl(filePath) && !filePath.startsWith('https://')) { + return { + error: { + status: 400, + message: 'Instagram media URLs must use HTTPS so Meta can download them', + }, + } + } + + const resolution = await resolveFileInputToUrl({ + file, + filePath, + userId, + requestId, + logger, + ttlSeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS, + preferKeyPresign: true, + }) + + if (resolution.error || !resolution.fileUrl) { + return { + error: resolution.error || { status: 400, message: `${label} is required` }, + } + } + + return applyInstagramConstraints(resolution.fileUrl, role, label, { + name, + size, + mimeType, + }) +} + +/** + * Resolve carousel media: file array, single file, or legacy comma-separated URL string + * (optional `video:` prefix per entry). + */ +export async function resolveInstagramCarouselMedia( + input: unknown, + userId: string, + requestId: string, + logger: Logger +): Promise<{ items?: ResolvedInstagramMedia[]; error?: { status: number; message: string } }> { + if (input == null || input === '') { + return { error: { status: 400, message: 'Carousel media is required' } } + } + + const items: ResolvedInstagramMedia[] = [] + + if (typeof input === 'string') { + const trimmed = input.trim() + if (!trimmed) { + return { error: { status: 400, message: 'Carousel media is required' } } + } + + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as unknown + return resolveInstagramCarouselMedia(parsed, userId, requestId, logger) + } catch { + // fall through to comma-separated URL parsing + } + } + + const entries = trimmed + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + + if (entries.length < 2 || entries.length > 10) { + return { error: { status: 400, message: 'Carousels require between 2 and 10 items' } } + } + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + const isVideoPrefixed = entry.toLowerCase().startsWith('video:') + const raw = isVideoPrefixed ? entry.slice('video:'.length).trim() : entry + const result = await resolveInstagramMedia({ + input: raw, + userId, + requestId, + logger, + role: isVideoPrefixed ? 'video' : 'image', + label: `Carousel item ${i + 1}`, + }) + if (result.error || !result.media) { + return { + error: result.error || { + status: 400, + message: `Failed to resolve carousel item ${i + 1}`, + }, + } + } + result.media.kind = isVideoPrefixed ? 'video' : 'image' + items.push(result.media) + } + + return { items } + } + + const list = Array.isArray(input) ? input : [input] + if (list.length < 2 || list.length > 10) { + return { error: { status: 400, message: 'Carousels require between 2 and 10 items' } } + } + + for (let i = 0; i < list.length; i++) { + const result = await resolveInstagramMedia({ + input: list[i], + userId, + requestId, + logger, + role: 'carousel', + label: `Carousel item ${i + 1}`, + }) + if (result.error || !result.media) { + return { + error: result.error || { status: 400, message: `Failed to resolve carousel item ${i + 1}` }, + } + } + items.push(result.media) + } + + return { items } +} diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 98849d6a9a4..4788fbe6fbe 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-11", + "updatedAt": "2026-07-12", "integrations": [ { "type": "onepassword", @@ -8918,6 +8918,118 @@ "integrationType": "security", "tags": ["secrets-management"] }, + { + "type": "instagram", + "slug": "instagram", + "name": "Instagram", + "description": "Publish content, moderate comments, and manage Instagram DMs", + "longDescription": "Integrate Instagram into workflows. Publish images, videos, Reels, stories, and carousels; moderate comments; send DMs; and pull account or media insights.", + "bgColor": "radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)", + "iconName": "InstagramIcon", + "docsUrl": "https://docs.sim.ai/integrations/instagram", + "operations": [ + { + "name": "Get Profile", + "description": "Get the connected Instagram professional account profile" + }, + { + "name": "List Media", + "description": "List recent media on the Instagram professional account" + }, + { + "name": "Get Media", + "description": "Get details for a specific Instagram media object" + }, + { + "name": "List Stories", + "description": "List active stories on the Instagram professional account" + }, + { + "name": "Publish Image", + "description": "Create and publish a single JPEG image post from an uploaded file or public HTTPS URL (polls until the container is ready)" + }, + { + "name": "Publish Video", + "description": "Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready)" + }, + { + "name": "Publish Reel", + "description": "Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready)" + }, + { + "name": "Publish Story", + "description": "Publish an image or video story from an uploaded file or public HTTPS URL" + }, + { + "name": "Publish Carousel", + "description": "Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs" + }, + { + "name": "Get Container Status", + "description": "Check the publishing status of a media container" + }, + { + "name": "Get Publishing Limit", + "description": "Check the content publishing rate limit usage for the account" + }, + { + "name": "List Comments", + "description": "List comments on an Instagram media object" + }, + { + "name": "Reply to Comment", + "description": "Reply to a comment on Instagram media" + }, + { + "name": "Hide Comment", + "description": "Hide or unhide a comment on Instagram media" + }, + { + "name": "Delete Comment", + "description": "Delete a comment on Instagram media" + }, + { + "name": "Set Comments Enabled", + "description": "Enable or disable comments on an Instagram media object" + }, + { + "name": "Private Reply", + "description": "Send a private Direct message reply to a commenter (one per commenter)" + }, + { + "name": "List Conversations", + "description": "List Instagram Direct conversations for the professional account" + }, + { + "name": "Get Conversation Messages", + "description": "List recent message ids/content in a conversation (only the last ~20 messages are fetchable)" + }, + { + "name": "Get Message", + "description": "Get a single Instagram Direct message by id (only recent messages are available)" + }, + { + "name": "Send Text Message", + "description": "Send a text Direct message. The recipient must have messaged the account first (24h window)." + }, + { + "name": "Get Account Insights", + "description": "Get insights metrics for the Instagram professional account" + }, + { + "name": "Get Media Insights", + "description": "Get insights metrics for a specific Instagram media object" + } + ], + "operationCount": 23, + "triggers": [], + "triggerCount": 0, + "authType": "oauth", + "oauthServiceId": "instagram", + "category": "tools", + "integrationType": "marketing", + "tags": ["marketing", "messaging", "automation"] + }, { "type": "instantly", "slug": "instantly", diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 4cdf616ae4a..3e7614acc5d 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -43,6 +43,7 @@ const EXPECTED_PROVIDER_BY_SLUG: Record = { jira: 'jira', 'jira-service-management': 'jira', linear: 'linear', + instagram: 'instagram', linkedin: 'linkedin', 'microsoft-dataverse': 'microsoft-dataverse', 'microsoft-excel': 'microsoft-excel', diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts index f4c641730ea..8e075a51fb8 100644 --- a/apps/sim/lib/oauth/index.ts +++ b/apps/sim/lib/oauth/index.ts @@ -1,3 +1,4 @@ +export * from './instagram' export * from './microsoft' export * from './oauth' export * from './types' diff --git a/apps/sim/lib/oauth/instagram.test.ts b/apps/sim/lib/oauth/instagram.test.ts new file mode 100644 index 00000000000..f767f20011e --- /dev/null +++ b/apps/sim/lib/oauth/instagram.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + INSTAGRAM_MIN_TOKEN_AGE_MS, + INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS, + isInstagramProvider, + shouldProactivelyRefreshInstagramToken, +} from '@/lib/oauth/instagram' + +describe('instagram oauth helpers', () => { + it('identifies the instagram provider', () => { + expect(isInstagramProvider('instagram')).toBe(true) + expect(isInstagramProvider('facebook')).toBe(false) + }) + + it('does not refresh when the token is already expired', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date(now.getTime() - 60_000), + updatedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + now, + }) + ).toBe(false) + }) + + it('does not refresh tokens younger than 24 hours', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000), + updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS + 60_000), + now, + }) + ).toBe(false) + }) + + it('refreshes when expiry is within the proactive window and the token is old enough', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date( + now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS - 1) * 24 * 60 * 60 * 1000 + ), + updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS - 60_000), + now, + }) + ).toBe(true) + }) + + it('does not refresh when plenty of lifetime remains', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date( + now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS + 5) * 24 * 60 * 60 * 1000 + ), + updatedAt: new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000), + now, + }) + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/oauth/instagram.ts b/apps/sim/lib/oauth/instagram.ts new file mode 100644 index 00000000000..3fe5be1c35a --- /dev/null +++ b/apps/sim/lib/oauth/instagram.ts @@ -0,0 +1,38 @@ +/** Refresh while the long-lived token still has this many days left. */ +export const INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS = 14 + +/** + * Meta rejects refresh until the long-lived token is at least 24 hours old. + * @see https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token/ + */ +export const INSTAGRAM_MIN_TOKEN_AGE_MS = 24 * 60 * 60 * 1000 + +export function isInstagramProvider(providerId: string): boolean { + return providerId === 'instagram' +} + +/** + * Whether an Instagram long-lived token should be refreshed before it expires. + * Meta cannot refresh expired tokens, so we must refresh while still valid. + */ +export function shouldProactivelyRefreshInstagramToken(options: { + accessTokenExpiresAt?: Date | null + updatedAt?: Date | null + now?: Date +}): boolean { + const now = options.now ?? new Date() + const expiresAt = options.accessTokenExpiresAt + if (!expiresAt || expiresAt <= now) { + return false + } + + const updatedAt = options.updatedAt + if (updatedAt && now.getTime() - updatedAt.getTime() < INSTAGRAM_MIN_TOKEN_AGE_MS) { + return false + } + + const proactiveThreshold = new Date( + now.getTime() + INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 + ) + return expiresAt <= proactiveThreshold +} diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index ff544e5e2e0..20876727b0c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -24,6 +24,7 @@ import { GoogleSheetsIcon, GoogleTasksIcon, HubspotIcon, + InstagramIcon, JiraIcon, LinearIcon, LinkedInIcon, @@ -975,6 +976,27 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'linkedin', }, + instagram: { + name: 'Instagram', + icon: InstagramIcon, + services: { + instagram: { + name: 'Instagram', + description: 'Publish content, moderate comments, and message on Instagram.', + providerId: 'instagram', + icon: InstagramIcon, + baseProviderIcon: InstagramIcon, + scopes: [ + 'instagram_business_basic', + 'instagram_business_content_publish', + 'instagram_business_manage_comments', + 'instagram_business_manage_messages', + 'instagram_business_manage_insights', + ], + }, + }, + defaultService: 'instagram', + }, salesforce: { name: 'Salesforce', icon: SalesforceIcon, @@ -1084,6 +1106,12 @@ interface ProviderAuthConfig { * instead of the default application/x-www-form-urlencoded. Used by Notion. */ useJsonBody?: boolean + /** + * Token refresh strategy. `instagram_long_lived` uses Meta's GET + * `refresh_access_token?grant_type=ig_refresh_token` flow instead of a + * standard OAuth refresh_token POST. + */ + refreshStrategy?: 'standard' | 'instagram_long_lived' /** * Body param name to use for the client identifier instead of the standard `client_id`. * TikTok requires `client_key` instead. @@ -1387,6 +1415,20 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: false, } } + case 'instagram': { + const { clientId, clientSecret } = getCredentials( + env.INSTAGRAM_CLIENT_ID, + env.INSTAGRAM_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://graph.instagram.com/refresh_access_token', + clientId, + clientSecret, + useBasicAuth: false, + supportsRefreshTokenRotation: true, + refreshStrategy: 'instagram_long_lived', + } + } case 'salesforce': { const { clientId, clientSecret } = getCredentials( env.SALESFORCE_CLIENT_ID, @@ -1559,6 +1601,66 @@ function extractErrorCode(value: unknown): string | undefined { */ const TOKEN_REFRESH_TIMEOUT_MS = 15_000 +async function refreshInstagramLongLivedToken( + config: ProviderAuthConfig, + longLivedToken: string, + providerId: string +): Promise { + const url = new URL(config.tokenEndpoint) + url.searchParams.set('grant_type', 'ig_refresh_token') + url.searchParams.set('access_token', longLivedToken) + + const response = await fetch(url.toString(), { + method: 'GET', + signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), + }) + + if (!response.ok) { + const errorText = await response.text() + let errorData: unknown = errorText + try { + errorData = JSON.parse(errorText) + } catch { + // keep text + } + logger.error('Instagram long-lived token refresh failed:', { + status: response.status, + statusText: response.statusText, + error: errorText, + parsedError: errorData, + providerId, + tokenEndpoint: config.tokenEndpoint, + }) + return { + ok: false, + errorCode: extractErrorCode(errorData), + message: `Failed to refresh token: ${response.status} ${errorText}`, + } + } + + const data = await response.json() + const accessToken = data.access_token + const expiresIn = data.expires_in || data.expiresIn || 5184000 + + if (!accessToken) { + logger.warn('No access token found in Instagram refresh response', { + providerId, + response: data, + }) + return { ok: false, message: 'No access token in refresh response' } + } + + logger.info('Instagram long-lived token refreshed successfully', { expiresIn, providerId }) + + // Instagram returns a new long-lived token; store it as both access and refresh. + return { + ok: true, + accessToken, + expiresIn, + refreshToken: accessToken, + } +} + export async function refreshOAuthToken( providerId: string, refreshToken: string @@ -1568,6 +1670,10 @@ export async function refreshOAuthToken( const config = getProviderAuthConfig(provider) + if (config.refreshStrategy === 'instagram_long_lived') { + return await refreshInstagramLongLivedToken(config, refreshToken, providerId) + } + const { headers, bodyParams, useJsonBody } = buildAuthRequest(config, refreshToken) const response = await fetch(config.tokenEndpoint, { diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 35d08c21e84..96b7f1a06eb 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -77,6 +77,7 @@ export type OAuthProvider = | 'hubspot' | 'salesforce' | 'linkedin' + | 'instagram' | 'shopify' | 'zoom' | 'wordpress' @@ -129,6 +130,7 @@ export type OAuthService = | 'hubspot' | 'salesforce' | 'linkedin' + | 'instagram' | 'shopify' | 'zoom' | 'wordpress' diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 92d48a611fa..7960aad288e 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -359,6 +359,13 @@ export const SCOPE_DESCRIPTIONS: Record = { // LinkedIn scopes w_member_social: 'Access LinkedIn profile', + // Instagram scopes (Business Login for Instagram) + instagram_business_basic: 'Access Instagram professional profile and media', + instagram_business_content_publish: 'Publish photos, videos, reels, and stories', + instagram_business_manage_comments: 'Read, reply to, hide, and delete comments', + instagram_business_manage_messages: 'Read conversations and send Instagram Direct messages', + instagram_business_manage_insights: 'Read account and media insights', + // Box scopes root_readwrite: 'Read and write all files and folders in Box account', root_readonly: 'Read all files and folders in Box account', diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 1077512cc97..330756f8166 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -40,6 +40,9 @@ export interface FileResolutionResult { } } +/** Default presigned download URL lifetime (5 minutes). */ +const DEFAULT_PRESIGNED_DOWNLOAD_TTL_SECONDS = 5 * 60 + /** * Options for resolving file input to a URL */ @@ -49,6 +52,46 @@ export interface ResolveFileInputOptions { userId: string requestId: string logger: Logger + /** + * Presigned download URL TTL in seconds. Defaults to 5 minutes. + */ + ttlSeconds?: number + /** + * When true and the file has a storage key, always mint a fresh presigned URL + * from the key (instead of reusing an existing non-internal URL). Use this when + * a third party must fetch the file over a longer window (e.g. Instagram/Meta). + */ + preferKeyPresign?: boolean +} + +async function presignFromStorageKey( + key: string, + contextHint: string | undefined, + userId: string, + requestId: string, + logger: Logger, + ttlSeconds: number +): Promise { + const context = resolveTrustedFileContext(key, contextHint) + const hasAccess = await verifyFileAccess(key, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key, + context, + }) + return { error: { status: 404, message: 'File not found' } } + } + + try { + const fileUrl = await StorageService.generatePresignedDownloadUrl(key, context, ttlSeconds) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`, { ttlSeconds }) + return { fileUrl } + } catch (error) { + logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + return { error: { status: 500, message: 'Failed to generate file access URL' } } + } } /** @@ -62,7 +105,15 @@ export interface ResolveFileInputOptions { export async function resolveFileInputToUrl( options: ResolveFileInputOptions ): Promise { - const { file, filePath, userId, requestId, logger } = options + const { + file, + filePath, + userId, + requestId, + logger, + ttlSeconds = DEFAULT_PRESIGNED_DOWNLOAD_TTL_SECONDS, + preferKeyPresign = false, + } = options if (file) { let userFile: UserFile @@ -77,11 +128,28 @@ export async function resolveFileInputToUrl( } } + if (preferKeyPresign && userFile.key) { + return presignFromStorageKey( + userFile.key, + userFile.context, + userId, + requestId, + logger, + ttlSeconds + ) + } + let fileUrl = userFile.url || '' // Handle internal URLs if (fileUrl && isInternalFileUrl(fileUrl)) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + const resolution = await resolveInternalFileUrl( + fileUrl, + userId, + requestId, + logger, + ttlSeconds + ) if (resolution.error) { return { error: resolution.error } } @@ -90,19 +158,14 @@ export async function resolveFileInputToUrl( // Generate presigned URL if we have a key but no URL if (!fileUrl && userFile.key) { - const context = resolveTrustedFileContext(userFile.key, userFile.context) - const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) - - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: userFile.key, - context, - }) - return { error: { status: 404, message: 'File not found' } } - } - - fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) + return presignFromStorageKey( + userFile.key, + userFile.context, + userId, + requestId, + logger, + ttlSeconds + ) } return { fileUrl } @@ -112,7 +175,13 @@ export async function resolveFileInputToUrl( let fileUrl = filePath if (isInternalFileUrl(filePath)) { - const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger) + const resolution = await resolveInternalFileUrl( + filePath, + userId, + requestId, + logger, + ttlSeconds + ) if (resolution.error) { return { error: resolution.error } } @@ -227,7 +296,8 @@ export async function resolveInternalFileUrl( filePath: string, userId: string, requestId: string, - logger: Logger + logger: Logger, + ttlSeconds: number = DEFAULT_PRESIGNED_DOWNLOAD_TTL_SECONDS ): Promise<{ fileUrl?: string; error?: { status: number; message: string } }> { if (!isInternalFileUrl(filePath)) { return { fileUrl: filePath } @@ -247,8 +317,12 @@ export async function resolveInternalFileUrl( return { error: { status: 404, message: 'File not found' } } } - const fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + const fileUrl = await StorageService.generatePresignedDownloadUrl( + storageKey, + context, + ttlSeconds + ) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`, { ttlSeconds }) return { fileUrl } } catch (error) { logger.error(`[${requestId}] Failed to generate presigned URL:`, error) diff --git a/apps/sim/tools/instagram/delete_comment.ts b/apps/sim/tools/instagram/delete_comment.ts new file mode 100644 index 00000000000..33bb7e84dfe --- /dev/null +++ b/apps/sim/tools/instagram/delete_comment.ts @@ -0,0 +1,62 @@ +import type { + InstagramDeleteCommentParams, + InstagramDeleteCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramDeleteCommentTool: ToolConfig< + InstagramDeleteCommentParams, + InstagramDeleteCommentResponse +> = { + id: 'instagram_delete_comment', + name: 'Instagram Delete Comment', + description: 'Delete a comment on Instagram media', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to delete', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.commentId.trim()}`), + method: 'DELETE', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await readGraphError(response), + } + } + + const data = await response.json().catch(() => ({ success: true })) + return { + success: true, + output: { success: data.success !== false }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the delete succeeded' }, + }, +} diff --git a/apps/sim/tools/instagram/get_account_insights.ts b/apps/sim/tools/instagram/get_account_insights.ts new file mode 100644 index 00000000000..3af2b77cab4 --- /dev/null +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -0,0 +1,135 @@ +import type { + InstagramGetAccountInsightsParams, + InstagramGetAccountInsightsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + parseCommaSeparated, + readGraphError, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetAccountInsightsTool: ToolConfig< + InstagramGetAccountInsightsParams, + InstagramGetAccountInsightsResponse +> = { + id: 'instagram_get_account_insights', + name: 'Instagram Get Account Insights', + description: 'Get insights metrics for the Instagram professional account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated metrics (e.g. reach,views,accounts_engaged,profile_views,follower_count)', + }, + period: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Aggregation period: day, week, days_28, lifetime, or total_over_range', + }, + since: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unix timestamp or date for range start', + }, + until: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unix timestamp or date for range end', + }, + metricType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional metric_type (e.g. time_series, total_value)', + }, + breakdown: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional breakdown dimension', + }, + timeframe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Required lookback window for demographic metrics', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/insights` : '/me/insights' + return graphUrl(path, { + metric: parseCommaSeparated(params.metrics).join(','), + period: params.period.trim(), + since: params.since?.trim() || undefined, + until: params.until?.trim() || undefined, + metric_type: params.metricType?.trim() || undefined, + breakdown: params.breakdown?.trim() || undefined, + timeframe: params.timeframe?.trim() || undefined, + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { insights: [] }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + insights: items.map((item: Record) => ({ + name: (item.name as string | undefined) ?? null, + period: (item.period as string | undefined) ?? null, + title: (item.title as string | undefined) ?? null, + description: (item.description as string | undefined) ?? null, + values: Array.isArray(item.values) ? item.values : [], + totalValue: item.total_value ?? null, + })), + }, + } + }, + + outputs: { + insights: { + type: 'json', + description: 'Insight metrics (name, period, title, description, values, totalValue)', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_container_status.ts b/apps/sim/tools/instagram/get_container_status.ts new file mode 100644 index 00000000000..43f62f8ee36 --- /dev/null +++ b/apps/sim/tools/instagram/get_container_status.ts @@ -0,0 +1,80 @@ +import type { + InstagramGetContainerStatusParams, + InstagramGetContainerStatusResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetContainerStatusTool: ToolConfig< + InstagramGetContainerStatusParams, + InstagramGetContainerStatusResponse +> = { + id: 'instagram_get_container_status', + name: 'Instagram Get Container Status', + description: 'Check the publishing status of a media container', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + containerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Media container id returned from a create/publish step', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.containerId.trim()}`, { fields: 'status_code,status' }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response, params): Promise => { + if (!response.ok) { + return { + success: false, + output: { + containerId: params?.containerId ?? '', + statusCode: null, + status: null, + }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { + containerId: params?.containerId ?? idString(data.id) ?? '', + statusCode: data.status_code ?? null, + status: data.status ?? null, + }, + } + }, + + outputs: { + containerId: { type: 'string', description: 'Container id' }, + statusCode: { + type: 'string', + description: 'EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED', + optional: true, + }, + status: { + type: 'string', + description: 'Detailed status message when available', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/get_conversation_messages.ts b/apps/sim/tools/instagram/get_conversation_messages.ts new file mode 100644 index 00000000000..817873fd71c --- /dev/null +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -0,0 +1,82 @@ +import type { + InstagramGetConversationMessagesParams, + InstagramGetConversationMessagesResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetConversationMessagesTool: ToolConfig< + InstagramGetConversationMessagesParams, + InstagramGetConversationMessagesResponse +> = { + id: 'instagram_get_conversation_messages', + name: 'Instagram Get Conversation Messages', + description: + 'List recent message ids/content in a conversation (only the last ~20 messages are fetchable)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + conversationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Conversation id from list_conversations', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.conversationId.trim()}`, { + fields: 'messages{id,created_time}', + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async ( + response, + params + ): Promise => { + if (!response.ok) { + return { + success: false, + output: { conversationId: params?.conversationId ?? '', messages: [] }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.messages?.data) ? data.messages.data : [] + + return { + success: true, + output: { + conversationId: params?.conversationId ?? idString(data.id) ?? '', + messages: items.map((item: Record) => ({ + id: String(item.id ?? ''), + createdTime: (item.created_time as string | undefined) ?? null, + })), + }, + } + }, + + outputs: { + conversationId: { type: 'string', description: 'Conversation id' }, + messages: { + type: 'json', + description: + 'Message references (id, createdTime). Use Get Message for sender, recipient, and text.', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts new file mode 100644 index 00000000000..eb1997bfb36 --- /dev/null +++ b/apps/sim/tools/instagram/get_media.ts @@ -0,0 +1,99 @@ +import type { InstagramGetMediaParams, InstagramGetMediaResponse } from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const MEDIA_FIELDS = + 'id,caption,media_type,media_product_type,media_url,permalink,timestamp,like_count,comments_count,children{id}' + +export const instagramGetMediaTool: ToolConfig = + { + id: 'instagram_get_media', + name: 'Instagram Get Media', + description: 'Get details for a specific Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.mediaId.trim()}`, { fields: MEDIA_FIELDS }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { + id: null, + caption: null, + mediaType: null, + mediaProductType: null, + mediaUrl: null, + permalink: null, + timestamp: null, + likeCount: null, + commentsCount: null, + children: [], + }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const children = Array.isArray(data.children?.data) + ? data.children.data.map((child: { id?: string }) => ({ id: String(child.id ?? '') })) + : [] + + return { + success: true, + output: { + id: idString(data.id), + caption: data.caption ?? null, + mediaType: data.media_type ?? null, + mediaProductType: data.media_product_type ?? null, + mediaUrl: data.media_url ?? null, + permalink: data.permalink ?? null, + timestamp: data.timestamp ?? null, + likeCount: data.like_count ?? null, + commentsCount: data.comments_count ?? null, + children, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Media id', optional: true }, + caption: { type: 'string', description: 'Caption text', optional: true }, + mediaType: { type: 'string', description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM', optional: true }, + mediaProductType: { + type: 'string', + description: 'Feed, Reels, or Stories product type', + optional: true, + }, + mediaUrl: { type: 'string', description: 'CDN media URL when available', optional: true }, + permalink: { type: 'string', description: 'Permalink to the post', optional: true }, + timestamp: { type: 'string', description: 'ISO timestamp', optional: true }, + likeCount: { type: 'number', description: 'Like count', optional: true }, + commentsCount: { type: 'number', description: 'Comments count', optional: true }, + children: { type: 'json', description: 'Carousel child media ids' }, + }, + } diff --git a/apps/sim/tools/instagram/get_media_insights.ts b/apps/sim/tools/instagram/get_media_insights.ts new file mode 100644 index 00000000000..b4f417dd81f --- /dev/null +++ b/apps/sim/tools/instagram/get_media_insights.ts @@ -0,0 +1,91 @@ +import type { + InstagramGetMediaInsightsParams, + InstagramGetMediaInsightsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + parseCommaSeparated, + readGraphError, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetMediaInsightsTool: ToolConfig< + InstagramGetMediaInsightsParams, + InstagramGetMediaInsightsResponse +> = { + id: 'instagram_get_media_insights', + name: 'Instagram Get Media Insights', + description: 'Get insights metrics for a specific Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated metrics (e.g. views,reach,likes,comments,saved,shares,total_interactions)', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.mediaId.trim()}/insights`, { + metric: parseCommaSeparated(params.metrics).join(','), + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { insights: [] }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + insights: items.map((item: Record) => ({ + name: (item.name as string | undefined) ?? null, + period: (item.period as string | undefined) ?? null, + title: (item.title as string | undefined) ?? null, + description: (item.description as string | undefined) ?? null, + values: Array.isArray(item.values) ? item.values : [], + totalValue: item.total_value ?? null, + })), + }, + } + }, + + outputs: { + insights: { + type: 'json', + description: 'Media insight metrics (name, period, title, description, values, totalValue)', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_message.ts b/apps/sim/tools/instagram/get_message.ts new file mode 100644 index 00000000000..164b1d44c01 --- /dev/null +++ b/apps/sim/tools/instagram/get_message.ts @@ -0,0 +1,88 @@ +import type { + InstagramGetMessageParams, + InstagramGetMessageResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetMessageTool: ToolConfig< + InstagramGetMessageParams, + InstagramGetMessageResponse +> = { + id: 'instagram_get_message', + name: 'Instagram Get Message', + description: 'Get a single Instagram Direct message by id (only recent messages are available)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message id', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.messageId.trim()}`, { + fields: 'id,created_time,from,to,message', + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { + id: null, + createdTime: null, + fromId: null, + fromUsername: null, + toId: null, + message: null, + }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const from = data.from as { id?: string | number; username?: string } | undefined + const toData = data.to?.data + const toFirst = Array.isArray(toData) ? toData[0] : undefined + + return { + success: true, + output: { + id: idString(data.id), + createdTime: data.created_time ?? null, + fromId: idString(from?.id), + fromUsername: from?.username ?? null, + toId: idString(toFirst?.id), + message: data.message ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Message id', optional: true }, + createdTime: { type: 'string', description: 'Created timestamp', optional: true }, + fromId: { type: 'string', description: 'Sender Instagram-scoped id', optional: true }, + fromUsername: { type: 'string', description: 'Sender username', optional: true }, + toId: { type: 'string', description: 'Recipient id', optional: true }, + message: { type: 'string', description: 'Message text', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/get_profile.ts b/apps/sim/tools/instagram/get_profile.ts new file mode 100644 index 00000000000..11b9b43f28f --- /dev/null +++ b/apps/sim/tools/instagram/get_profile.ts @@ -0,0 +1,92 @@ +import type { + InstagramGetProfileParams, + InstagramGetProfileResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const PROFILE_FIELDS = + 'user_id,id,username,name,account_type,profile_picture_url,followers_count,follows_count,media_count' + +export const instagramGetProfileTool: ToolConfig< + InstagramGetProfileParams, + InstagramGetProfileResponse +> = { + id: 'instagram_get_profile', + name: 'Instagram Get Profile', + description: 'Get the connected Instagram professional account profile', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + }, + + request: { + url: () => graphUrl('/me', { fields: PROFILE_FIELDS }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { + userId: null, + id: null, + username: null, + name: null, + accountType: null, + profilePictureUrl: null, + followersCount: null, + followsCount: null, + mediaCount: null, + }, + error: await readGraphError(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + userId: idString(data.user_id), + id: idString(data.id), + username: data.username ?? null, + name: data.name ?? null, + accountType: data.account_type ?? null, + profilePictureUrl: data.profile_picture_url ?? null, + followersCount: data.followers_count ?? null, + followsCount: data.follows_count ?? null, + mediaCount: data.media_count ?? null, + }, + } + }, + + outputs: { + userId: { + type: 'string', + description: 'Instagram professional account user_id', + optional: true, + }, + id: { type: 'string', description: 'Graph object id', optional: true }, + username: { type: 'string', description: 'Instagram username', optional: true }, + name: { type: 'string', description: 'Display name', optional: true }, + accountType: { type: 'string', description: 'Business or Media_Creator', optional: true }, + profilePictureUrl: { type: 'string', description: 'Profile picture URL', optional: true }, + followersCount: { type: 'number', description: 'Follower count', optional: true }, + followsCount: { type: 'number', description: 'Following count', optional: true }, + mediaCount: { type: 'number', description: 'Media count', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/get_publishing_limit.ts b/apps/sim/tools/instagram/get_publishing_limit.ts new file mode 100644 index 00000000000..c836b1539fb --- /dev/null +++ b/apps/sim/tools/instagram/get_publishing_limit.ts @@ -0,0 +1,86 @@ +import type { + InstagramGetPublishingLimitParams, + InstagramGetPublishingLimitResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetPublishingLimitTool: ToolConfig< + InstagramGetPublishingLimitParams, + InstagramGetPublishingLimitResponse +> = { + id: 'instagram_get_publishing_limit', + name: 'Instagram Get Publishing Limit', + description: 'Check the content publishing rate limit usage for the account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() + ? `/${params.igUserId.trim()}/content_publishing_limit` + : '/me/content_publishing_limit' + return graphUrl(path, { fields: 'quota_usage,config' }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { quotaUsage: null, config: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const first = Array.isArray(data.data) ? data.data[0] : data + + return { + success: true, + output: { + quotaUsage: first?.quota_usage ?? null, + config: first?.config + ? { + quotaTotal: first.config.quota_total ?? null, + quotaDuration: first.config.quota_duration ?? null, + } + : null, + }, + } + }, + + outputs: { + quotaUsage: { + type: 'number', + description: 'Number of publishes used in the current window', + optional: true, + }, + config: { + type: 'json', + description: 'Quota config (quotaTotal, quotaDuration)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/hide_comment.ts b/apps/sim/tools/instagram/hide_comment.ts new file mode 100644 index 00000000000..244ee806409 --- /dev/null +++ b/apps/sim/tools/instagram/hide_comment.ts @@ -0,0 +1,68 @@ +import type { + InstagramHideCommentParams, + InstagramHideCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramHideCommentTool: ToolConfig< + InstagramHideCommentParams, + InstagramHideCommentResponse +> = { + id: 'instagram_hide_comment', + name: 'Instagram Hide Comment', + description: 'Hide or unhide a comment on Instagram media', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id', + }, + hide: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'True to hide, false to unhide', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.commentId.trim()}`, { hide: String(params.hide) }), + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { success: data.success === true || data.success === undefined }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the hide/unhide succeeded' }, + }, +} diff --git a/apps/sim/tools/instagram/index.ts b/apps/sim/tools/instagram/index.ts new file mode 100644 index 00000000000..78a7afe6623 --- /dev/null +++ b/apps/sim/tools/instagram/index.ts @@ -0,0 +1,24 @@ +export { instagramDeleteCommentTool } from '@/tools/instagram/delete_comment' +export { instagramGetAccountInsightsTool } from '@/tools/instagram/get_account_insights' +export { instagramGetContainerStatusTool } from '@/tools/instagram/get_container_status' +export { instagramGetConversationMessagesTool } from '@/tools/instagram/get_conversation_messages' +export { instagramGetMediaTool } from '@/tools/instagram/get_media' +export { instagramGetMediaInsightsTool } from '@/tools/instagram/get_media_insights' +export { instagramGetMessageTool } from '@/tools/instagram/get_message' +export { instagramGetProfileTool } from '@/tools/instagram/get_profile' +export { instagramGetPublishingLimitTool } from '@/tools/instagram/get_publishing_limit' +export { instagramHideCommentTool } from '@/tools/instagram/hide_comment' +export { instagramListCommentsTool } from '@/tools/instagram/list_comments' +export { instagramListConversationsTool } from '@/tools/instagram/list_conversations' +export { instagramListMediaTool } from '@/tools/instagram/list_media' +export { instagramListStoriesTool } from '@/tools/instagram/list_stories' +export { instagramPrivateReplyTool } from '@/tools/instagram/private_reply' +export { instagramPublishCarouselTool } from '@/tools/instagram/publish_carousel' +export { instagramPublishImageTool } from '@/tools/instagram/publish_image' +export { instagramPublishReelTool } from '@/tools/instagram/publish_reel' +export { instagramPublishStoryTool } from '@/tools/instagram/publish_story' +export { instagramPublishVideoTool } from '@/tools/instagram/publish_video' +export { instagramReplyToCommentTool } from '@/tools/instagram/reply_to_comment' +export { instagramSendTextMessageTool } from '@/tools/instagram/send_text_message' +export { instagramSetCommentsEnabledTool } from '@/tools/instagram/set_comments_enabled' +export * from '@/tools/instagram/types' diff --git a/apps/sim/tools/instagram/list_comments.ts b/apps/sim/tools/instagram/list_comments.ts new file mode 100644 index 00000000000..e3f5f92b1a9 --- /dev/null +++ b/apps/sim/tools/instagram/list_comments.ts @@ -0,0 +1,96 @@ +import type { + InstagramListCommentsParams, + InstagramListCommentsResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramListCommentsTool: ToolConfig< + InstagramListCommentsParams, + InstagramListCommentsResponse +> = { + id: 'instagram_list_comments', + name: 'Instagram List Comments', + description: 'List comments on an Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of comments to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.mediaId.trim()}/comments`, { + fields: 'id,text,username,timestamp,like_count,hidden', + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { comments: [], nextCursor: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + comments: items.map((item: Record) => ({ + id: String(item.id ?? ''), + text: (item.text as string | undefined) ?? null, + username: (item.username as string | undefined) ?? null, + timestamp: (item.timestamp as string | undefined) ?? null, + likeCount: (item.like_count as number | undefined) ?? null, + hidden: (item.hidden as boolean | undefined) ?? null, + })), + // Graph includes cursors on every page; only `paging.next` signals another page. + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + comments: { + type: 'json', + description: 'Comments (id, text, username, timestamp, likeCount, hidden)', + }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/list_conversations.ts b/apps/sim/tools/instagram/list_conversations.ts new file mode 100644 index 00000000000..de5e78f4291 --- /dev/null +++ b/apps/sim/tools/instagram/list_conversations.ts @@ -0,0 +1,97 @@ +import type { + InstagramListConversationsParams, + InstagramListConversationsResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramListConversationsTool: ToolConfig< + InstagramListConversationsParams, + InstagramListConversationsResponse +> = { + id: 'instagram_list_conversations', + name: 'Instagram List Conversations', + description: 'List Instagram Direct conversations for the professional account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of conversations to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() + ? `/${params.igUserId.trim()}/conversations` + : '/me/conversations' + return graphUrl(path, { + platform: 'instagram', + fields: 'id,updated_time', + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { conversations: [], nextCursor: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + conversations: items.map((item: Record) => ({ + id: String(item.id ?? ''), + updatedTime: (item.updated_time as string | undefined) ?? null, + })), + // Graph includes cursors on every page; only `paging.next` signals another page. + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + conversations: { + type: 'json', + description: 'Conversations (id, updatedTime)', + }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/list_media.ts b/apps/sim/tools/instagram/list_media.ts new file mode 100644 index 00000000000..02860922163 --- /dev/null +++ b/apps/sim/tools/instagram/list_media.ts @@ -0,0 +1,106 @@ +import type { InstagramListMediaParams, InstagramListMediaResponse } from '@/tools/instagram/types' +import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const MEDIA_FIELDS = + 'id,caption,media_type,media_product_type,media_url,permalink,timestamp,like_count,comments_count' + +export const instagramListMediaTool: ToolConfig< + InstagramListMediaParams, + InstagramListMediaResponse +> = { + id: 'instagram_list_media', + name: 'Instagram List Media', + description: 'List recent media on the Instagram professional account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of media items to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous list_media response', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/media` : '/me/media' + return graphUrl(path, { + fields: MEDIA_FIELDS, + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { media: [], nextCursor: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + media: items.map((item: Record) => ({ + id: String(item.id ?? ''), + caption: (item.caption as string | undefined) ?? null, + mediaType: (item.media_type as string | undefined) ?? null, + mediaProductType: (item.media_product_type as string | undefined) ?? null, + mediaUrl: (item.media_url as string | undefined) ?? null, + permalink: (item.permalink as string | undefined) ?? null, + timestamp: (item.timestamp as string | undefined) ?? null, + likeCount: (item.like_count as number | undefined) ?? null, + commentsCount: (item.comments_count as number | undefined) ?? null, + })), + // Graph includes cursors on every page; only `paging.next` signals another page. + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + media: { + type: 'json', + description: + 'List of media objects (id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount)', + }, + nextCursor: { + type: 'string', + description: 'Pagination cursor for the next page', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/list_stories.ts b/apps/sim/tools/instagram/list_stories.ts new file mode 100644 index 00000000000..d8a0f41ec95 --- /dev/null +++ b/apps/sim/tools/instagram/list_stories.ts @@ -0,0 +1,79 @@ +import type { + InstagramListStoriesParams, + InstagramListStoriesResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramListStoriesTool: ToolConfig< + InstagramListStoriesParams, + InstagramListStoriesResponse +> = { + id: 'instagram_list_stories', + name: 'Instagram List Stories', + description: 'List active stories on the Instagram professional account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/stories` : '/me/stories' + return graphUrl(path, { + fields: 'id,media_type,media_url,timestamp', + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { stories: [] }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + stories: items.map((item: Record) => ({ + id: String(item.id ?? ''), + mediaType: (item.media_type as string | undefined) ?? null, + mediaUrl: (item.media_url as string | undefined) ?? null, + timestamp: (item.timestamp as string | undefined) ?? null, + })), + }, + } + }, + + outputs: { + stories: { + type: 'json', + description: 'Active stories (id, mediaType, mediaUrl, timestamp)', + }, + }, +} diff --git a/apps/sim/tools/instagram/private_reply.ts b/apps/sim/tools/instagram/private_reply.ts new file mode 100644 index 00000000000..8e0a11d4bd8 --- /dev/null +++ b/apps/sim/tools/instagram/private_reply.ts @@ -0,0 +1,92 @@ +import type { + InstagramPrivateReplyParams, + InstagramPrivateReplyResponse, +} from '@/tools/instagram/types' +import { graphUrl, idString, jsonBearerHeaders, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPrivateReplyTool: ToolConfig< + InstagramPrivateReplyParams, + InstagramPrivateReplyResponse +> = { + id: 'instagram_private_reply', + name: 'Instagram Private Reply', + description: 'Send a private Direct message reply to a commenter (one per commenter)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to privately reply to', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Private reply text', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => jsonBearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { comment_id: params.commentId.trim() }, + message: { text: params.message }, + }), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: await readGraphError(response), + } + } + + const data = (await response.json()) as { + message_id?: string | number + recipient_id?: string | number + } + return { + success: true, + output: { + messageId: idString(data.message_id), + recipientId: idString(data.recipient_id), + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'Sent message id', optional: true }, + recipientId: { + type: 'string', + description: 'Instagram-scoped recipient id', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/publish_carousel.ts b/apps/sim/tools/instagram/publish_carousel.ts new file mode 100644 index 00000000000..ee245950e59 --- /dev/null +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -0,0 +1,67 @@ +import type { + InstagramPublishCarouselParams, + InstagramPublishResponse, +} from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishCarouselTool: ToolConfig< + InstagramPublishCarouselParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_carousel', + name: 'Instagram Publish Carousel', + description: 'Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + media: { + type: 'file[]', + required: true, + visibility: 'user-or-llm', + description: + '2-10 media files, or a comma-separated public URL string (prefix videos with video:)', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Carousel caption', + }, + }, + + request: { + url: '/api/tools/instagram/publish-carousel', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishCarouselParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + media: params.media, + caption: params.caption, + }), + }, + + transformResponse: createPublishTransform('Failed to publish carousel'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_image.ts b/apps/sim/tools/instagram/publish_image.ts new file mode 100644 index 00000000000..de299085171 --- /dev/null +++ b/apps/sim/tools/instagram/publish_image.ts @@ -0,0 +1,78 @@ +import type { InstagramPublishImageParams, InstagramPublishResponse } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishImageTool: ToolConfig< + InstagramPublishImageParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_image', + name: 'Instagram Publish Image', + description: + 'Create and publish a single JPEG image post from an uploaded file or public HTTPS URL (polls until the container is ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + image: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'JPEG image file or public HTTPS URL (Meta will download it)', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post caption (max 2200 characters)', + }, + altText: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Accessibility alt text for the image', + }, + isAiGenerated: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Mark the post as AI-generated', + }, + }, + + request: { + url: '/api/tools/instagram/publish-image', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishImageParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + image: params.image, + caption: params.caption, + altText: params.altText, + isAiGenerated: params.isAiGenerated, + }), + }, + + transformResponse: createPublishTransform('Failed to publish image'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_reel.ts b/apps/sim/tools/instagram/publish_reel.ts new file mode 100644 index 00000000000..db492275999 --- /dev/null +++ b/apps/sim/tools/instagram/publish_reel.ts @@ -0,0 +1,85 @@ +import type { InstagramPublishReelParams, InstagramPublishResponse } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishReelTool: ToolConfig< + InstagramPublishReelParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_reel', + name: 'Instagram Publish Reel', + description: + 'Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + video: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'Reel video file or public HTTPS URL', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reel caption', + }, + cover: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + shareToFeed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Also share the Reel to the main feed', + }, + thumbOffset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Frame offset in milliseconds for the cover thumbnail', + }, + }, + + request: { + url: '/api/tools/instagram/publish-reel', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishReelParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + video: params.video, + cover: params.cover, + caption: params.caption, + shareToFeed: params.shareToFeed, + thumbOffset: params.thumbOffset, + }), + }, + + transformResponse: createPublishTransform('Failed to publish reel'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_story.ts b/apps/sim/tools/instagram/publish_story.ts new file mode 100644 index 00000000000..f1a4b1424ec --- /dev/null +++ b/apps/sim/tools/instagram/publish_story.ts @@ -0,0 +1,56 @@ +import type { InstagramPublishResponse, InstagramPublishStoryParams } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishStoryTool: ToolConfig< + InstagramPublishStoryParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_story', + name: 'Instagram Publish Story', + description: 'Publish an image or video story from an uploaded file or public HTTPS URL', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + media: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'JPEG image or MP4/MOV video file, or a public HTTPS URL', + }, + }, + + request: { + url: '/api/tools/instagram/publish-story', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishStoryParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + media: params.media, + }), + }, + + transformResponse: createPublishTransform('Failed to publish story'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_video.ts b/apps/sim/tools/instagram/publish_video.ts new file mode 100644 index 00000000000..14d53157c6c --- /dev/null +++ b/apps/sim/tools/instagram/publish_video.ts @@ -0,0 +1,71 @@ +import type { InstagramPublishResponse, InstagramPublishVideoParams } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishVideoTool: ToolConfig< + InstagramPublishVideoParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_video', + name: 'Instagram Publish Video', + description: + 'Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + video: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'Video file or public HTTPS URL', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post caption', + }, + cover: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + }, + + request: { + url: '/api/tools/instagram/publish-video', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishVideoParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + video: params.video, + cover: params.cover, + caption: params.caption, + }), + }, + + transformResponse: createPublishTransform('Failed to publish video'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/reply_to_comment.ts b/apps/sim/tools/instagram/reply_to_comment.ts new file mode 100644 index 00000000000..5f58091f417 --- /dev/null +++ b/apps/sim/tools/instagram/reply_to_comment.ts @@ -0,0 +1,70 @@ +import type { + InstagramReplyToCommentParams, + InstagramReplyToCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramReplyToCommentTool: ToolConfig< + InstagramReplyToCommentParams, + InstagramReplyToCommentResponse +> = { + id: 'instagram_reply_to_comment', + name: 'Instagram Reply to Comment', + description: 'Reply to a comment on Instagram media', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to reply to', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Reply text', + }, + }, + + request: { + // Graph comment endpoints document message as a query/form parameter, not + // a JSON body (same style as hide_comment and set_comments_enabled). + url: (params) => graphUrl(`/${params.commentId.trim()}/replies`, { message: params.message }), + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { id: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { id: idString(data.id) }, + } + }, + + outputs: { + id: { type: 'string', description: 'Created reply comment id', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/send_text_message.ts b/apps/sim/tools/instagram/send_text_message.ts new file mode 100644 index 00000000000..647b42957d8 --- /dev/null +++ b/apps/sim/tools/instagram/send_text_message.ts @@ -0,0 +1,89 @@ +import type { + InstagramSendTextMessageParams, + InstagramSendTextMessageResponse, +} from '@/tools/instagram/types' +import { graphUrl, idString, jsonBearerHeaders, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramSendTextMessageTool: ToolConfig< + InstagramSendTextMessageParams, + InstagramSendTextMessageResponse +> = { + id: 'instagram_send_text_message', + name: 'Instagram Send Text Message', + description: + 'Send a text Direct message. The recipient must have messaged the account first (24h window).', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + recipientId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram-scoped user id (IGSID) of the recipient', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message text (max 1000 bytes UTF-8)', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => jsonBearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { id: params.recipientId.trim() }, + message: { text: params.message }, + }), + }, + + transformResponse: async (response, params): Promise => { + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: params?.recipientId?.trim() ?? null }, + error: await readGraphError(response), + } + } + + const data = (await response.json()) as { + message_id?: string | number + recipient_id?: string | number + } + return { + success: true, + output: { + messageId: idString(data.message_id), + recipientId: idString(data.recipient_id) ?? params?.recipientId?.trim() ?? null, + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'Sent message id', optional: true }, + recipientId: { type: 'string', description: 'Recipient id', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/set_comments_enabled.ts b/apps/sim/tools/instagram/set_comments_enabled.ts new file mode 100644 index 00000000000..4cada4a260b --- /dev/null +++ b/apps/sim/tools/instagram/set_comments_enabled.ts @@ -0,0 +1,69 @@ +import type { + InstagramSetCommentsEnabledParams, + InstagramSetCommentsEnabledResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramSetCommentsEnabledTool: ToolConfig< + InstagramSetCommentsEnabledParams, + InstagramSetCommentsEnabledResponse +> = { + id: 'instagram_set_comments_enabled', + name: 'Instagram Set Comments Enabled', + description: 'Enable or disable comments on an Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + commentEnabled: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'True to enable comments, false to disable', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.mediaId.trim()}`, { comment_enabled: String(params.commentEnabled) }), + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { success: data.success === true || data.success === undefined }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the update succeeded' }, + }, +} diff --git a/apps/sim/tools/instagram/types.ts b/apps/sim/tools/instagram/types.ts new file mode 100644 index 00000000000..ad141d62882 --- /dev/null +++ b/apps/sim/tools/instagram/types.ts @@ -0,0 +1,347 @@ +import type { ToolResponse } from '@/tools/types' + +export interface InstagramAccessParams { + accessToken: string + igUserId?: string +} + +export interface InstagramGetProfileParams { + accessToken: string +} + +export interface InstagramGetProfileResponse extends ToolResponse { + output: { + userId: string | null + id: string | null + username: string | null + name: string | null + accountType: string | null + profilePictureUrl: string | null + followersCount: number | null + followsCount: number | null + mediaCount: number | null + } +} + +export interface InstagramListMediaParams extends InstagramAccessParams { + limit?: number + after?: string +} + +export interface InstagramListMediaResponse extends ToolResponse { + output: { + media: Array<{ + id: string + caption: string | null + mediaType: string | null + mediaProductType: string | null + mediaUrl: string | null + permalink: string | null + timestamp: string | null + likeCount: number | null + commentsCount: number | null + }> + nextCursor: string | null + } +} + +export interface InstagramGetMediaParams { + accessToken: string + mediaId: string +} + +export interface InstagramGetMediaResponse extends ToolResponse { + output: { + id: string | null + caption: string | null + mediaType: string | null + mediaProductType: string | null + mediaUrl: string | null + permalink: string | null + timestamp: string | null + likeCount: number | null + commentsCount: number | null + children: Array<{ id: string }> + } +} + +export interface InstagramListStoriesParams extends InstagramAccessParams {} + +export interface InstagramListStoriesResponse extends ToolResponse { + output: { + stories: Array<{ + id: string + mediaType: string | null + mediaUrl: string | null + timestamp: string | null + }> + } +} + +/** Uploaded UserFile object, or a public HTTPS URL string (advanced paste / legacy). */ +export type InstagramMediaInput = object | string + +export interface InstagramPublishImageParams extends InstagramAccessParams { + image: InstagramMediaInput + caption?: string + altText?: string + isAiGenerated?: boolean +} + +export interface InstagramPublishVideoParams extends InstagramAccessParams { + video: InstagramMediaInput + caption?: string + cover?: InstagramMediaInput +} + +export interface InstagramPublishReelParams extends InstagramAccessParams { + video: InstagramMediaInput + caption?: string + cover?: InstagramMediaInput + shareToFeed?: boolean + thumbOffset?: number +} + +export interface InstagramPublishStoryParams extends InstagramAccessParams { + media: InstagramMediaInput +} + +export interface InstagramPublishCarouselParams extends InstagramAccessParams { + /** File array, single file, or comma-separated public URLs (prefix videos with video:). */ + media: InstagramMediaInput | InstagramMediaInput[] + caption?: string +} + +export interface InstagramPublishResponse extends ToolResponse { + output: { + containerId: string | null + mediaId: string | null + statusCode: string | null + } +} + +export interface InstagramGetContainerStatusParams { + accessToken: string + containerId: string +} + +export interface InstagramGetContainerStatusResponse extends ToolResponse { + output: { + containerId: string + statusCode: string | null + status: string | null + } +} + +export interface InstagramGetPublishingLimitParams extends InstagramAccessParams {} + +export interface InstagramGetPublishingLimitResponse extends ToolResponse { + output: { + quotaUsage: number | null + config: { + quotaTotal: number | null + quotaDuration: number | null + } | null + } +} + +export interface InstagramListCommentsParams { + accessToken: string + mediaId: string + limit?: number + after?: string +} + +export interface InstagramListCommentsResponse extends ToolResponse { + output: { + comments: Array<{ + id: string + text: string | null + username: string | null + timestamp: string | null + likeCount: number | null + hidden: boolean | null + }> + nextCursor: string | null + } +} + +export interface InstagramReplyToCommentParams { + accessToken: string + commentId: string + message: string +} + +export interface InstagramReplyToCommentResponse extends ToolResponse { + output: { + id: string | null + } +} + +export interface InstagramHideCommentParams { + accessToken: string + commentId: string + hide: boolean +} + +export interface InstagramHideCommentResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramDeleteCommentParams { + accessToken: string + commentId: string +} + +export interface InstagramDeleteCommentResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramSetCommentsEnabledParams { + accessToken: string + mediaId: string + commentEnabled: boolean +} + +export interface InstagramSetCommentsEnabledResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramPrivateReplyParams extends InstagramAccessParams { + commentId: string + message: string +} + +export interface InstagramPrivateReplyResponse extends ToolResponse { + output: { + messageId: string | null + recipientId: string | null + } +} + +export interface InstagramListConversationsParams extends InstagramAccessParams { + limit?: number + after?: string +} + +export interface InstagramListConversationsResponse extends ToolResponse { + output: { + conversations: Array<{ + id: string + updatedTime: string | null + }> + nextCursor: string | null + } +} + +export interface InstagramGetConversationMessagesParams { + accessToken: string + conversationId: string +} + +export interface InstagramGetConversationMessagesResponse extends ToolResponse { + output: { + conversationId: string + messages: Array<{ + id: string + createdTime: string | null + }> + } +} + +export interface InstagramGetMessageParams { + accessToken: string + messageId: string +} + +export interface InstagramGetMessageResponse extends ToolResponse { + output: { + id: string | null + createdTime: string | null + fromId: string | null + fromUsername: string | null + toId: string | null + message: string | null + } +} + +export interface InstagramSendTextMessageParams extends InstagramAccessParams { + recipientId: string + message: string +} + +export interface InstagramSendTextMessageResponse extends ToolResponse { + output: { + messageId: string | null + recipientId: string | null + } +} + +export interface InstagramGetAccountInsightsParams extends InstagramAccessParams { + metrics: string + period: string + since?: string + until?: string + metricType?: string + breakdown?: string + timeframe?: string +} + +export interface InstagramGetAccountInsightsResponse extends ToolResponse { + output: { + insights: Array<{ + name: string | null + period: string | null + title: string | null + description: string | null + values: unknown[] + totalValue: unknown + }> + } +} + +export interface InstagramGetMediaInsightsParams { + accessToken: string + mediaId: string + metrics: string +} + +export interface InstagramGetMediaInsightsResponse extends ToolResponse { + output: { + insights: Array<{ + name: string | null + period: string | null + title: string | null + description: string | null + values: unknown[] + totalValue: unknown + }> + } +} + +export type InstagramResponse = + | InstagramGetProfileResponse + | InstagramListMediaResponse + | InstagramGetMediaResponse + | InstagramListStoriesResponse + | InstagramPublishResponse + | InstagramGetContainerStatusResponse + | InstagramGetPublishingLimitResponse + | InstagramListCommentsResponse + | InstagramReplyToCommentResponse + | InstagramHideCommentResponse + | InstagramDeleteCommentResponse + | InstagramSetCommentsEnabledResponse + | InstagramPrivateReplyResponse + | InstagramListConversationsResponse + | InstagramGetConversationMessagesResponse + | InstagramGetMessageResponse + | InstagramSendTextMessageResponse + | InstagramGetAccountInsightsResponse + | InstagramGetMediaInsightsResponse diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts new file mode 100644 index 00000000000..065359f7e93 --- /dev/null +++ b/apps/sim/tools/instagram/utils.ts @@ -0,0 +1,233 @@ +import { sleep } from '@sim/utils/helpers' +import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram' +import type { InstagramPublishResponse } from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +export function bearerHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + } +} + +/** For the messaging endpoints, which take a JSON body (publish endpoints are form-encoded). */ +export function jsonBearerHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + } +} + +export function graphUrl(path: string, query?: Record): string { + const url = new URL( + path.startsWith('http') + ? path + : `${INSTAGRAM_GRAPH_BASE}${path.startsWith('/') ? path : `/${path}`}` + ) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== '') { + url.searchParams.set(key, value) + } + } + } + return url.toString() +} + +/** + * Graph may serialize IDs as strings or numbers. Normalize to a string (or + * null) so downstream tools can safely call .trim() on wired ID outputs. + */ +export function idString(value: unknown): string | null { + if (value == null || value === '') return null + return String(value) +} + +export async function readGraphError(response: Response): Promise { + const text = await response.text() + try { + const json = JSON.parse(text) as { error?: { message?: string; code?: number } } + if (json.error?.message) { + return json.error.message + } + } catch { + // keep raw text + } + return text || response.statusText +} + +export async function resolveIgUserId(accessToken: string, igUserId?: string): Promise { + if (igUserId && igUserId.trim().length > 0) { + return igUserId.trim() + } + + const response = await fetch(graphUrl('/me', { fields: 'user_id' }), { + headers: bearerHeaders(accessToken), + }) + + if (!response.ok) { + throw new Error(`Failed to resolve Instagram user id: ${await readGraphError(response)}`) + } + + // Only /me's user_id is the Instagram professional account ID that publish + // and messaging paths expect; /me's id is an app-scoped ID from a different + // ID space, so never fall back to it. + const data = (await response.json()) as { user_id?: string | number } + if (data.user_id == null || data.user_id === '') { + throw new Error('Instagram /me response did not include a user_id') + } + return String(data.user_id) +} + +export type ContainerStatusCode = 'EXPIRED' | 'ERROR' | 'FINISHED' | 'IN_PROGRESS' | 'PUBLISHED' + +export async function getContainerStatus( + accessToken: string, + containerId: string +): Promise<{ statusCode: ContainerStatusCode | null; status: string | null }> { + const response = await fetch(graphUrl(`/${containerId}`, { fields: 'status_code,status' }), { + headers: bearerHeaders(accessToken), + }) + + if (!response.ok) { + throw new Error(`Failed to get container status: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { status_code?: string; status?: string } + return { + statusCode: (data.status_code as ContainerStatusCode | undefined) ?? null, + status: data.status ?? null, + } +} + +// Meta recommends checking once per minute for no more than five minutes. +// Six attempts = an immediate check plus five minute-spaced checks, so the +// full five-minute window is covered before timing out. +const POLL_INTERVAL_MS = 60_000 +const POLL_MAX_ATTEMPTS = 6 + +export async function waitForContainerReady( + accessToken: string, + containerId: string +): Promise<{ statusCode: ContainerStatusCode; status: string | null }> { + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + const { statusCode, status } = await getContainerStatus(accessToken, containerId) + + if (statusCode === 'FINISHED' || statusCode === 'PUBLISHED') { + return { statusCode, status } + } + if (statusCode === 'ERROR' || statusCode === 'EXPIRED') { + throw new Error( + `Instagram media container ${containerId} failed with status ${statusCode}${status ? `: ${status}` : ''}` + ) + } + + if (attempt < POLL_MAX_ATTEMPTS - 1) { + await sleep(POLL_INTERVAL_MS) + } + } + + throw new Error(`Timed out waiting for Instagram container ${containerId} to finish processing`) +} + +/** + * Graph content publishing endpoints document query/form parameters, not JSON + * bodies (JSON is only documented for the messaging endpoints), so publish + * POSTs are sent form-encoded. + */ +async function postGraphForm( + accessToken: string, + path: string, + params: Record +): Promise { + const form = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + form.set(key, String(value)) + } + } + + return fetch(graphUrl(path), { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), + }) +} + +export async function createMediaContainer( + accessToken: string, + igUserId: string, + body: Record +): Promise { + const response = await postGraphForm(accessToken, `/${igUserId}/media`, body) + + if (!response.ok) { + throw new Error(`Failed to create media container: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { id?: string | number } + const id = idString(data.id) + if (!id) { + throw new Error('Create media container response missing id') + } + return id +} + +export async function publishMediaContainer( + accessToken: string, + igUserId: string, + creationId: string +): Promise { + const response = await postGraphForm(accessToken, `/${igUserId}/media_publish`, { + creation_id: creationId, + }) + + if (!response.ok) { + throw new Error(`Failed to publish media: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { id?: string | number } + const id = idString(data.id) + if (!id) { + throw new Error('Publish media response missing id') + } + return id +} + +/** Shared output schema for the five publish tools (image, video, reel, story, carousel). */ +export const PUBLISH_OUTPUTS: ToolConfig['outputs'] = { + containerId: { type: 'string', description: 'Media container id', optional: true }, + mediaId: { type: 'string', description: 'Published media id', optional: true }, + statusCode: { type: 'string', description: 'Final container status', optional: true }, +} + +/** + * Shared transformResponse for the publish tools, which all proxy through + * internal API routes returning `{ success, output, error }`. + */ +export function createPublishTransform(fallbackError: string) { + return async (response: Response): Promise => { + const data = await response.json() + const output = data.output || { containerId: null, mediaId: null, statusCode: null } + if (!response.ok || data.success === false) { + return { success: false, output, error: data.error || fallbackError } + } + return { success: true, output } + } +} + +export function parseCommaSeparated(value?: string): string[] { + if (!value) return [] + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) +} + +/** Clamp Graph pagination `limit` to a safe range (default 25, max 100). */ +export function clampGraphLimit(limit: number | undefined, fallback = 25): number { + if (limit == null || Number.isNaN(Number(limit))) return fallback + return Math.min(100, Math.max(1, Math.floor(Number(limit)))) +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index e6f33250a0d..ba844fa6148 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1828,6 +1828,31 @@ import { infisicalListSecretsTool, infisicalUpdateSecretTool, } from '@/tools/infisical' +import { + instagramDeleteCommentTool, + instagramGetAccountInsightsTool, + instagramGetContainerStatusTool, + instagramGetConversationMessagesTool, + instagramGetMediaInsightsTool, + instagramGetMediaTool, + instagramGetMessageTool, + instagramGetProfileTool, + instagramGetPublishingLimitTool, + instagramHideCommentTool, + instagramListCommentsTool, + instagramListConversationsTool, + instagramListMediaTool, + instagramListStoriesTool, + instagramPrivateReplyTool, + instagramPublishCarouselTool, + instagramPublishImageTool, + instagramPublishReelTool, + instagramPublishStoryTool, + instagramPublishVideoTool, + instagramReplyToCommentTool, + instagramSendTextMessageTool, + instagramSetCommentsEnabledTool, +} from '@/tools/instagram' import { instantlyActivateCampaignTool, instantlyCreateCampaignTool, @@ -4853,6 +4878,29 @@ export const tools: Record = { hex_update_collection: hexUpdateCollectionTool, hex_update_group: hexUpdateGroupTool, hex_update_project: hexUpdateProjectTool, + instagram_delete_comment: instagramDeleteCommentTool, + instagram_get_account_insights: instagramGetAccountInsightsTool, + instagram_get_container_status: instagramGetContainerStatusTool, + instagram_get_conversation_messages: instagramGetConversationMessagesTool, + instagram_get_media: instagramGetMediaTool, + instagram_get_media_insights: instagramGetMediaInsightsTool, + instagram_get_message: instagramGetMessageTool, + instagram_get_profile: instagramGetProfileTool, + instagram_get_publishing_limit: instagramGetPublishingLimitTool, + instagram_hide_comment: instagramHideCommentTool, + instagram_list_comments: instagramListCommentsTool, + instagram_list_conversations: instagramListConversationsTool, + instagram_list_media: instagramListMediaTool, + instagram_list_stories: instagramListStoriesTool, + instagram_private_reply: instagramPrivateReplyTool, + instagram_publish_carousel: instagramPublishCarouselTool, + instagram_publish_image: instagramPublishImageTool, + instagram_publish_reel: instagramPublishReelTool, + instagram_publish_story: instagramPublishStoryTool, + instagram_publish_video: instagramPublishVideoTool, + instagram_reply_to_comment: instagramReplyToCommentTool, + instagram_send_text_message: instagramSendTextMessageTool, + instagram_set_comments_enabled: instagramSetCommentsEnabledTool, instantly_activate_campaign: instantlyActivateCampaignTool, instantly_create_campaign: instantlyCreateCampaignTool, instantly_create_lead: instantlyCreateLeadTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index d8694b3f6bc..c6f786704a6 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 927, - zodRoutes: 927, + totalRoutes: 935, + zodRoutes: 935, nonZodRoutes: 0, } as const @@ -25,7 +25,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookRawFetches: 0, clientSameOriginApiFetches: 0, doubleCasts: 8, - rawJsonReads: 8, + rawJsonReads: 6, untypedResponses: 0, annotationsMissingReason: 0, } as const