From 41e0e64e21b5c43650d1bae5986ba186a83fdd9d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 9 Jul 2026 17:25:46 -0700 Subject: [PATCH 1/8] feat(instagram): add Instagram Login OAuth, tools, and block --- apps/docs/components/icons.tsx | 15 + apps/docs/components/ui/icon-mapping.ts | 2 + .../content/docs/en/integrations/ashby.mdx | 9 +- .../content/docs/en/integrations/gitlab.mdx | 4 +- .../content/docs/en/integrations/gmail.mdx | 2 +- .../docs/en/integrations/instagram.mdx | 466 ++++++++++++ .../content/docs/en/integrations/meta.json | 1 + apps/sim/.env.example | 4 + .../app/api/auth/instagram/authorize/route.ts | 75 ++ .../auth/oauth2/callback/instagram/route.ts | 314 ++++++++ apps/sim/blocks/blocks/instagram.ts | 687 ++++++++++++++++++ apps/sim/blocks/registry-maps.ts | 3 + apps/sim/components/icons.tsx | 15 + .../hooks/queries/oauth/oauth-connections.ts | 6 + .../lib/api/contracts/oauth-connections.ts | 26 + apps/sim/lib/core/config/env.ts | 2 + apps/sim/lib/integrations/icon-mapping.ts | 2 + apps/sim/lib/integrations/integrations.json | 114 ++- .../lib/integrations/oauth-service.test.ts | 1 + apps/sim/lib/oauth/oauth.ts | 106 +++ apps/sim/lib/oauth/types.ts | 2 + apps/sim/lib/oauth/utils.ts | 7 + apps/sim/tools/instagram/delete_comment.ts | 62 ++ .../tools/instagram/get_account_insights.ts | 128 ++++ .../tools/instagram/get_container_status.ts | 80 ++ .../instagram/get_conversation_messages.ts | 87 +++ apps/sim/tools/instagram/get_media.ts | 106 +++ .../sim/tools/instagram/get_media_insights.ts | 88 +++ apps/sim/tools/instagram/get_message.ts | 88 +++ apps/sim/tools/instagram/get_profile.ts | 92 +++ .../tools/instagram/get_publishing_limit.ts | 86 +++ apps/sim/tools/instagram/hide_comment.ts | 68 ++ apps/sim/tools/instagram/index.ts | 24 + apps/sim/tools/instagram/list_comments.ts | 96 +++ .../sim/tools/instagram/list_conversations.ts | 97 +++ apps/sim/tools/instagram/list_media.ts | 112 +++ apps/sim/tools/instagram/list_stories.ts | 79 ++ apps/sim/tools/instagram/private_reply.ts | 111 +++ apps/sim/tools/instagram/publish_carousel.ts | 162 +++++ apps/sim/tools/instagram/publish_image.ts | 131 ++++ apps/sim/tools/instagram/publish_reel.ts | 131 ++++ apps/sim/tools/instagram/publish_story.ts | 133 ++++ apps/sim/tools/instagram/publish_video.ts | 121 +++ apps/sim/tools/instagram/reply_to_comment.ts | 69 ++ apps/sim/tools/instagram/send_text_message.ts | 120 +++ .../tools/instagram/set_comments_enabled.ts | 69 ++ apps/sim/tools/instagram/types.ts | 343 +++++++++ apps/sim/tools/instagram/utils.ts | 165 +++++ apps/sim/tools/registry.ts | 48 ++ 49 files changed, 4753 insertions(+), 6 deletions(-) create mode 100644 apps/docs/content/docs/en/integrations/instagram.mdx create mode 100644 apps/sim/app/api/auth/instagram/authorize/route.ts create mode 100644 apps/sim/app/api/auth/oauth2/callback/instagram/route.ts create mode 100644 apps/sim/blocks/blocks/instagram.ts create mode 100644 apps/sim/tools/instagram/delete_comment.ts create mode 100644 apps/sim/tools/instagram/get_account_insights.ts create mode 100644 apps/sim/tools/instagram/get_container_status.ts create mode 100644 apps/sim/tools/instagram/get_conversation_messages.ts create mode 100644 apps/sim/tools/instagram/get_media.ts create mode 100644 apps/sim/tools/instagram/get_media_insights.ts create mode 100644 apps/sim/tools/instagram/get_message.ts create mode 100644 apps/sim/tools/instagram/get_profile.ts create mode 100644 apps/sim/tools/instagram/get_publishing_limit.ts create mode 100644 apps/sim/tools/instagram/hide_comment.ts create mode 100644 apps/sim/tools/instagram/index.ts create mode 100644 apps/sim/tools/instagram/list_comments.ts create mode 100644 apps/sim/tools/instagram/list_conversations.ts create mode 100644 apps/sim/tools/instagram/list_media.ts create mode 100644 apps/sim/tools/instagram/list_stories.ts create mode 100644 apps/sim/tools/instagram/private_reply.ts create mode 100644 apps/sim/tools/instagram/publish_carousel.ts create mode 100644 apps/sim/tools/instagram/publish_image.ts create mode 100644 apps/sim/tools/instagram/publish_reel.ts create mode 100644 apps/sim/tools/instagram/publish_story.ts create mode 100644 apps/sim/tools/instagram/publish_video.ts create mode 100644 apps/sim/tools/instagram/reply_to_comment.ts create mode 100644 apps/sim/tools/instagram/send_text_message.ts create mode 100644 apps/sim/tools/instagram/set_comments_enabled.ts create mode 100644 apps/sim/tools/instagram/types.ts create mode 100644 apps/sim/tools/instagram/utils.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index ea1eeea8a10..d19c32ca2ba 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1442,6 +1442,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/ashby.mdx b/apps/docs/content/docs/en/integrations/ashby.mdx index b6b27f6f70f..baa92879323 100644 --- a/apps/docs/content/docs/en/integrations/ashby.mdx +++ b/apps/docs/content/docs/en/integrations/ashby.mdx @@ -1109,6 +1109,7 @@ Trigger workflow when a new application is submitted | ↳ `currentInterviewStage` | object | currentInterviewStage output from the tool | | ↳ `id` | string | Current interview stage UUID | | ↳ `title` | string | Current interview stage title | +| ↳ `stageType` | string | Current interview stage type \(e.g., Lead, Applied, Interview, Offer\) | | ↳ `job` | object | job output from the tool | | ↳ `id` | string | Job UUID | | ↳ `title` | string | Job title | @@ -1163,6 +1164,7 @@ Trigger workflow when a candidate is hired | ↳ `currentInterviewStage` | object | currentInterviewStage output from the tool | | ↳ `id` | string | Current interview stage UUID | | ↳ `title` | string | Current interview stage title | +| ↳ `stageType` | string | Current interview stage type \(e.g., Lead, Applied, Interview, Offer\) | | ↳ `job` | object | job output from the tool | | ↳ `id` | string | Job UUID | | ↳ `title` | string | Job title | @@ -1204,6 +1206,7 @@ Trigger workflow when a candidate changes interview stages | ↳ `currentInterviewStage` | object | currentInterviewStage output from the tool | | ↳ `id` | string | Current interview stage UUID | | ↳ `title` | string | Current interview stage title | +| ↳ `stageType` | string | Current interview stage type \(e.g., Lead, Applied, Interview, Offer\) | | ↳ `job` | object | job output from the tool | | ↳ `id` | string | Job UUID | | ↳ `title` | string | Job title | @@ -1231,7 +1234,7 @@ Trigger workflow when a new job is created | ↳ `title` | string | Job title | | ↳ `confidential` | boolean | Whether the job is confidential | | ↳ `status` | string | Job status \(Open, Closed, Draft, Archived\) | -| ↳ `employmentType` | string | Employment type \(Full-time, Part-time, etc.\) | +| ↳ `employmentType` | string | Employment type \(FullTime, PartTime, Intern, Contract\) | --- @@ -1254,8 +1257,8 @@ Trigger workflow when a new offer is created | `offer` | object | offer output from the tool | | ↳ `id` | string | Offer UUID | | ↳ `applicationId` | string | Associated application UUID | -| ↳ `acceptanceStatus` | string | Offer acceptance status \(Accepted, Declined, Pending, Created, Cancelled, WaitingOnResponse\) | -| ↳ `offerStatus` | string | Offer process status \(WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnCandidateResponse, CandidateAccepted, CandidateRejected, OfferCancelled\) | +| ↳ `acceptanceStatus` | string | Offer acceptance status \(Accepted, Declined, Pending, Created, Cancelled\) | +| ↳ `offerStatus` | string | Offer process status \(WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnApprovalDefinition, WaitingOnCandidateResponse, CandidateRejected, CandidateAccepted, OfferCancelled\) | | ↳ `decidedAt` | string | Offer decision timestamp \(ISO 8601\). Typically null at creation; populated after candidate responds. | | ↳ `latestVersion` | object | latestVersion output from the tool | | ↳ `id` | string | Latest offer version UUID | diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index adb905bdbee..bc977d46bad 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -882,6 +882,7 @@ Trigger workflow when an issue is opened, updated, or closed in GitLab | ↳ `description` | string | Issue description | | ↳ `confidential` | boolean | Whether the issue is confidential | | ↳ `url` | string | Issue URL | +| ↳ `work_item_type` | string | Work item type \(e.g. Issue, Incident, Task\); GitLab 17.2+ only | --- @@ -912,7 +913,8 @@ Trigger workflow when a merge request is opened, updated, or merged in GitLab | ↳ `action` | string | Action \(open, close, reopen, update, merge, etc.\) | | ↳ `source_branch` | string | Source branch | | ↳ `target_branch` | string | Target branch | -| ↳ `merge_status` | string | Merge status | +| ↳ `merge_status` | string | Merge status \(deprecated by GitLab\) | +| ↳ `detailed_merge_status` | string | Detailed merge status | | ↳ `draft` | boolean | Whether the merge request is a draft | | ↳ `url` | string | Merge request URL | diff --git a/apps/docs/content/docs/en/integrations/gmail.mdx b/apps/docs/content/docs/en/integrations/gmail.mdx index 643ff1fdf35..0804c48b196 100644 --- a/apps/docs/content/docs/en/integrations/gmail.mdx +++ b/apps/docs/content/docs/en/integrations/gmail.mdx @@ -351,7 +351,7 @@ Triggers when new emails are received in Gmail (requires Gmail credentials) | ↳ `date` | string | Email date in ISO format | | ↳ `bodyText` | string | Plain text email body | | ↳ `bodyHtml` | string | HTML email body | -| ↳ `labels` | string | Email labels array | +| ↳ `labels` | array | Email labels array | | ↳ `hasAttachments` | boolean | Whether email has attachments | | ↳ `attachments` | file[] | Array of email attachments as files \(if includeAttachments is enabled\) | | `timestamp` | string | Event timestamp | 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..7458da66b08 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/instagram.mdx @@ -0,0 +1,466 @@ +--- +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 | +| `fields` | string | No | Comma-separated media fields to request | + +#### 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 | +| `fields` | string | No | Comma-separated fields to request | + +#### 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 a public URL + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `imageUrl` | string | Yes | Public HTTPS URL of a JPEG image \(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 | +| --------- | ---- | ----------- | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Final container status | + +### `instagram_publish_video` + +Create and publish a feed video from a public 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\) | +| `videoUrl` | string | Yes | Public HTTPS URL of the video file | +| `caption` | string | No | Post caption | +| `coverUrl` | string | No | Optional public JPEG cover image URL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Final container status | + +### `instagram_publish_reel` + +Create and publish a Reel from a public video URL (polls until ready) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `videoUrl` | string | Yes | Public HTTPS URL of the Reel video | +| `caption` | string | No | Reel caption | +| `coverUrl` | string | No | Optional public JPEG cover image 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 | +| --------- | ---- | ----------- | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Final container status | + +### `instagram_publish_story` + +Publish an image or video story from a public URL + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `imageUrl` | string | No | Public HTTPS JPEG URL for an image story | +| `videoUrl` | string | No | Public HTTPS video URL for a video story | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Final container status | + +### `instagram_publish_carousel` + +Publish a carousel of up to 10 images/videos from public URLs + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | +| `mediaUrls` | string | Yes | Comma-separated public media URLs \(max 10\). Prefix video URLs with video: \(e.g. https://.../a.jpg,video:https://.../b.mp4\) | +| `caption` | string | No | Carousel caption | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `containerId` | string | Carousel container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Final status | + +### `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 | + +### `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 | Messages \(id, createdTime, fromId, fromUsername, message\) | + +### `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 | + +#### 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, values\) | + + diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index ae36529830d..4165406b754 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 4083be6cdb0..f0231320032 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -93,6 +93,10 @@ 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= + # Admin API (Optional - for self-hosted GitOps) # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. # Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces 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..da610cc4067 --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -0,0 +1,75 @@ +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 { getCanonicalScopesForProvider } from '@/lib/oauth/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 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, + }) + + const returnUrl = parsed.data.query.returnUrl + 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/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts new file mode 100644 index 00000000000..5c144a023f1 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -0,0 +1,314 @@ +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 { 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' +const INSTAGRAM_GRAPH_VERSION = 'v22.0' + +interface ShortLivedTokenPayload { + access_token?: string + user_id?: string | number + permissions?: 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( + `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}/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 + id?: string + username?: string + name?: string + } + + const igUserId = + (typeof profile.user_id === 'string' && profile.user_id) || + (typeof shortLived.user_id === 'string' + ? shortLived.user_id + : shortLived.user_id != null + ? String(shortLived.user_id) + : null) || + (typeof profile.id === 'string' ? profile.id : null) + + if (!igUserId) { + logger.error('Instagram profile response missing user id', { profile }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`) + ) + } + + const permissions = + typeof shortLived.permissions === 'string' && shortLived.permissions.length > 0 + ? shortLived.permissions + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + : getCanonicalScopesForProvider('instagram') + 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/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts new file mode 100644 index 00000000000..905442449f0 --- /dev/null +++ b/apps/sim/blocks/blocks/instagram.ts @@ -0,0 +1,687 @@ +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 type { InstagramResponse } from '@/tools/instagram/types' + +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, + bgColor: '#E4405F', + 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, + }, + + { + id: 'imageUrl', + title: 'Image URL', + type: 'short-input', + placeholder: 'https://example.com/image.jpg', + condition: { + field: 'operation', + value: ['instagram_publish_image', 'instagram_publish_story'], + }, + required: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'videoUrl', + title: 'Video URL', + type: 'short-input', + placeholder: 'https://example.com/video.mp4 (for stories: use image OR video URL, not both)', + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel', 'instagram_publish_story'], + }, + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'mediaUrls', + title: 'Media URLs', + type: 'long-input', + placeholder: 'Comma-separated public URLs (prefix videos with video:)', + condition: { field: 'operation', value: 'instagram_publish_carousel' }, + 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). +Maximum 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: 'coverUrl', + title: 'Cover URL', + type: 'short-input', + placeholder: 'https://example.com/cover.jpg', + mode: 'advanced', + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'shareToFeed', + title: 'Share to Feed', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + 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,profile_views,follower_count,website_clicks +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: '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, ...rest } = params + const result: Record = { + credential: oauthCredential, + } + + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + if (key === 'operation' || key === 'credential' || key === 'manualCredential') 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' }, + imageUrl: { type: 'string', description: 'Public HTTPS image URL' }, + videoUrl: { type: 'string', description: 'Public HTTPS video URL' }, + mediaUrls: { type: 'string', description: 'Comma-separated media URLs for carousel' }, + caption: { type: 'string', description: 'Post caption' }, + altText: { type: 'string', description: 'Image accessibility alt text' }, + isAiGenerated: { type: 'boolean', description: 'Whether the image is AI-generated' }, + coverUrl: { type: 'string', description: 'Cover image URL for video or Reel' }, + 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' }, + 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: 'Messages (id, createdTime, fromId, fromUsername, message)', + }, + 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 unread messages, 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. Confirm a public HTTPS JPEG URL for the image 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 trends, run Get Account Insights with comma-separated metrics and a period such as day, week, or days_28.\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 fd1d8de138a..f0bae62ceee 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, @@ -471,6 +472,7 @@ export const BLOCK_REGISTRY: Record = { incidentio: IncidentioBlock, infisical: InfisicalBlock, input_trigger: InputTriggerBlock, + instagram: InstagramBlock, instantly: InstantlyBlock, intercom: IntercomBlock, intercom_v2: IntercomV2Block, @@ -763,6 +765,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/components/icons.tsx b/apps/sim/components/icons.tsx index ea1eeea8a10..d19c32ca2ba 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1442,6 +1442,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_v2: IntercomIcon, jina: JinaAIIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 9d543582283..e9a1cbb8bd4 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-07", + "updatedAt": "2026-07-09", "integrations": [ { "type": "onepassword", @@ -8930,6 +8930,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": "#E4405F", + "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 a public URL" + }, + { + "name": "Publish Video", + "description": "Create and publish a feed video from a public URL (published as a Reel shared to the feed; polls until ready)" + }, + { + "name": "Publish Reel", + "description": "Create and publish a Reel from a public video URL (polls until ready)" + }, + { + "name": "Publish Story", + "description": "Publish an image or video story from a public URL" + }, + { + "name": "Publish Carousel", + "description": "Publish a carousel of up to 10 images/videos from public 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/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 8f6b17100fe..55ad0dde226 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, @@ -948,6 +949,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, @@ -1057,6 +1079,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' } /** @@ -1340,6 +1368,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, @@ -1512,6 +1554,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 @@ -1521,6 +1623,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 bd1de917bf2..cee16f26d24 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -57,6 +57,7 @@ export type OAuthProvider = | 'hubspot' | 'salesforce' | 'linkedin' + | 'instagram' | 'shopify' | 'zoom' | 'wordpress' @@ -108,6 +109,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 6d903a6cd1c..22c31d12ca5 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -351,6 +351,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/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..26885cb588c --- /dev/null +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -0,0 +1,128 @@ +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', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/insights` : '/me/insights' + return graphUrl(path, { + metric: parseCommaSeparated(params.metrics).join(',') || params.metrics.trim(), + 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, + }) + }, + 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..fb93f4ac1d4 --- /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, 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 ?? 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..0c4f4a342d0 --- /dev/null +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -0,0 +1,87 @@ +import type { + InstagramGetConversationMessagesParams, + InstagramGetConversationMessagesResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, 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,from,to,message}', + }), + 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 ?? data.id ?? '', + messages: items.map((item: Record) => { + const from = item.from as { id?: string; username?: string } | undefined + return { + id: String(item.id ?? ''), + createdTime: (item.created_time as string | undefined) ?? null, + fromId: from?.id ?? null, + fromUsername: from?.username ?? null, + message: (item.message as string | undefined) ?? null, + } + }), + }, + } + }, + + outputs: { + conversationId: { type: 'string', description: 'Conversation id' }, + messages: { + type: 'json', + description: 'Messages (id, createdTime, fromId, fromUsername, message)', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts new file mode 100644 index 00000000000..230e2640c03 --- /dev/null +++ b/apps/sim/tools/instagram/get_media.ts @@ -0,0 +1,106 @@ +import type { InstagramGetMediaParams, InstagramGetMediaResponse } from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const DEFAULT_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', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated fields to request', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.mediaId.trim()}`, { fields: params.fields || DEFAULT_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: data.id ?? null, + 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..a41ab0f418b --- /dev/null +++ b/apps/sim/tools/instagram/get_media_insights.ts @@ -0,0 +1,88 @@ +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(',') || params.metrics.trim(), + }), + 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, + values: Array.isArray(item.values) ? item.values : [], + })), + }, + } + }, + + outputs: { + insights: { + type: 'json', + description: 'Media insight metrics (name, period, values)', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_message.ts b/apps/sim/tools/instagram/get_message.ts new file mode 100644 index 00000000000..61838efecfb --- /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, 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; username?: string } | undefined + const toData = data.to?.data + const toFirst = Array.isArray(toData) ? toData[0] : undefined + + return { + success: true, + output: { + id: data.id ?? null, + createdTime: data.created_time ?? null, + fromId: from?.id ?? null, + fromUsername: from?.username ?? null, + toId: toFirst?.id ?? null, + 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..db82eaf8143 --- /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, 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: data.user_id ?? null, + id: data.id ?? null, + 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..c353d9a438d --- /dev/null +++ b/apps/sim/tools/instagram/list_media.ts @@ -0,0 +1,112 @@ +import type { InstagramListMediaParams, InstagramListMediaResponse } from '@/tools/instagram/types' +import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const DEFAULT_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', + }, + fields: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Comma-separated media fields to request', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/media` : '/me/media' + return graphUrl(path, { + fields: params.fields || DEFAULT_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..e9bea98ac7d --- /dev/null +++ b/apps/sim/tools/instagram/private_reply.ts @@ -0,0 +1,111 @@ +import type { + InstagramPrivateReplyParams, + InstagramPrivateReplyResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError, resolveIgUserId } 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: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + method: 'GET', + headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + }, + + postProcess: async (result, params) => { + if (!result.success) { + return { + success: false, + output: { messageId: null }, + error: result.error || 'Failed to resolve Instagram account', + } + } + + try { + const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) + const response = await fetch(graphUrl(`/${igUserId}/messages`), { + method: 'POST', + headers: bearerHeaders(params.accessToken), + body: JSON.stringify({ + recipient: { comment_id: params.commentId.trim() }, + message: { text: params.message }, + }), + }) + + if (!response.ok) { + return { + success: false, + output: { messageId: null }, + error: await readGraphError(response), + } + } + + const data = (await response.json()) as { message_id?: string } + return { + success: true, + output: { messageId: data.message_id ?? null }, + } + } catch (error) { + return { + success: false, + output: { messageId: null }, + error: error instanceof Error ? error.message : 'Failed to send private reply', + } + } + }, + + transformResponse: async (response) => { + if (!response.ok) { + return { + success: false, + output: { messageId: null }, + error: `Failed to resolve Instagram account: ${response.statusText}`, + } + } + return { success: true, output: { messageId: null } } + }, + + outputs: { + messageId: { type: 'string', description: 'Sent message 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..934d6921993 --- /dev/null +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -0,0 +1,162 @@ +import type { + InstagramPublishCarouselParams, + InstagramPublishResponse, +} from '@/tools/instagram/types' +import { + createMediaContainer, + parseCommaSeparated, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +/** + * Parse media URL entries. Prefix with `video:` for video items, otherwise treated as images. + * Example: `https://cdn.example.com/a.jpg,video:https://cdn.example.com/b.mp4` + */ +function parseCarouselItems(mediaUrls: string): Array<{ type: 'image' | 'video'; url: string }> { + return parseCommaSeparated(mediaUrls).map((entry) => { + if (entry.toLowerCase().startsWith('video:')) { + return { type: 'video' as const, url: entry.slice('video:'.length).trim() } + } + return { type: 'image' as const, url: entry } + }) +} + +export const instagramPublishCarouselTool: ToolConfig< + InstagramPublishCarouselParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_carousel', + name: 'Instagram Publish Carousel', + description: 'Publish a carousel of up to 10 images/videos from public 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)', + }, + mediaUrls: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated public media URLs (max 10). Prefix video URLs with video: (e.g. https://.../a.jpg,video:https://.../b.mp4)', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Carousel caption', + }, + }, + + request: { + url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + method: 'GET', + headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + }, + + postProcess: async (result, params) => { + if (!result.success) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: result.error || 'Failed to resolve Instagram account', + } + } + + const items = parseCarouselItems(params.mediaUrls) + if (items.length === 0) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: 'Provide at least one media URL', + } + } + if (items.length > 10) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: 'Carousels support a maximum of 10 items', + } + } + + try { + const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) + const childIds: string[] = [] + + for (const item of items) { + const childBody: Record = { + is_carousel_item: true, + } + if (item.type === 'video') { + childBody.media_type = 'VIDEO' + childBody.video_url = item.url + } else { + childBody.image_url = item.url + } + + const childId = await createMediaContainer(params.accessToken, igUserId, childBody) + await waitForContainerReady(params.accessToken, childId) + childIds.push(childId) + } + + const parentBody: Record = { + media_type: 'CAROUSEL', + children: childIds.join(','), + } + if (params.caption) parentBody.caption = params.caption + + const containerId = await createMediaContainer(params.accessToken, igUserId, parentBody) + const { statusCode } = await waitForContainerReady(params.accessToken, containerId) + const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) + + return { + success: true, + output: { containerId, mediaId, statusCode }, + } + } catch (error) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: error instanceof Error ? error.message : 'Failed to publish carousel', + } + } + }, + + transformResponse: async (response) => { + if (!response.ok) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: `Failed to resolve Instagram account: ${response.statusText}`, + } + } + return { + success: true, + output: { containerId: null, mediaId: null, statusCode: null }, + } + }, + + outputs: { + containerId: { type: 'string', description: 'Carousel container id', optional: true }, + mediaId: { type: 'string', description: 'Published media id', optional: true }, + statusCode: { type: 'string', description: 'Final status', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/publish_image.ts b/apps/sim/tools/instagram/publish_image.ts new file mode 100644 index 00000000000..472ba248c2a --- /dev/null +++ b/apps/sim/tools/instagram/publish_image.ts @@ -0,0 +1,131 @@ +import type { InstagramPublishImageParams, InstagramPublishResponse } from '@/tools/instagram/types' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} 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 a public 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)', + }, + imageUrl: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Public HTTPS URL of a JPEG image (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: { + // Dummy request — real work happens in postProcess + url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + method: 'GET', + headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + }, + + postProcess: async (result, params) => { + if (!result.success) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: result.error || 'Failed to resolve Instagram account', + } + } + + try { + const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) + const body: Record = { + image_url: params.imageUrl, + } + if (params.caption) body.caption = params.caption + if (params.altText) body.alt_text = params.altText + if (params.isAiGenerated === true) body.is_ai_generated = true + + const containerId = await createMediaContainer(params.accessToken, igUserId, body) + // Meta downloads/processes the image asynchronously; publishing before + // status_code=FINISHED returns "Media ID is not available" (code 9007). + const { statusCode } = await waitForContainerReady(params.accessToken, containerId) + const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) + + return { + success: true, + output: { + containerId, + mediaId, + statusCode, + }, + } + } catch (error) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: error instanceof Error ? error.message : 'Failed to publish image', + } + } + }, + + transformResponse: async (response) => { + if (!response.ok) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: `Failed to resolve Instagram account: ${response.statusText}`, + } + } + return { + success: true, + output: { containerId: null, mediaId: null, statusCode: null }, + } + }, + + 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 }, + }, +} diff --git a/apps/sim/tools/instagram/publish_reel.ts b/apps/sim/tools/instagram/publish_reel.ts new file mode 100644 index 00000000000..2fa96f12dcc --- /dev/null +++ b/apps/sim/tools/instagram/publish_reel.ts @@ -0,0 +1,131 @@ +import type { InstagramPublishReelParams, InstagramPublishResponse } from '@/tools/instagram/types' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} 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 a public video 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)', + }, + videoUrl: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Public HTTPS URL of the Reel video', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reel caption', + }, + coverUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional public JPEG cover image 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: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + method: 'GET', + headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + }, + + postProcess: async (result, params) => { + if (!result.success) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: result.error || 'Failed to resolve Instagram account', + } + } + + try { + const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) + const body: Record = { + media_type: 'REELS', + video_url: params.videoUrl, + } + if (params.caption) body.caption = params.caption + if (params.coverUrl) body.cover_url = params.coverUrl + if (params.shareToFeed !== undefined) body.share_to_feed = params.shareToFeed + if (params.thumbOffset != null) body.thumb_offset = params.thumbOffset + + const containerId = await createMediaContainer(params.accessToken, igUserId, body) + const { statusCode } = await waitForContainerReady(params.accessToken, containerId) + const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) + + return { + success: true, + output: { containerId, mediaId, statusCode }, + } + } catch (error) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: error instanceof Error ? error.message : 'Failed to publish reel', + } + } + }, + + transformResponse: async (response) => { + if (!response.ok) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: `Failed to resolve Instagram account: ${response.statusText}`, + } + } + return { + success: true, + output: { containerId: null, mediaId: null, statusCode: null }, + } + }, + + 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 }, + }, +} diff --git a/apps/sim/tools/instagram/publish_story.ts b/apps/sim/tools/instagram/publish_story.ts new file mode 100644 index 00000000000..92ae5591fb0 --- /dev/null +++ b/apps/sim/tools/instagram/publish_story.ts @@ -0,0 +1,133 @@ +import type { InstagramPublishResponse, InstagramPublishStoryParams } from '@/tools/instagram/types' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} 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 a public 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)', + }, + imageUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Public HTTPS JPEG URL for an image story', + }, + videoUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Public HTTPS video URL for a video story', + }, + }, + + request: { + url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + method: 'GET', + headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + }, + + postProcess: async (result, params) => { + if (!result.success) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: result.error || 'Failed to resolve Instagram account', + } + } + + const imageUrl = params.imageUrl?.trim() || undefined + const videoUrl = params.videoUrl?.trim() || undefined + + if (!imageUrl && !videoUrl) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: 'Provide either imageUrl or videoUrl for a story', + } + } + + if (imageUrl && videoUrl) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: 'Provide only one of imageUrl or videoUrl for a story, not both', + } + } + + try { + const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) + const body: Record = { + media_type: 'STORIES', + } + if (videoUrl) { + body.video_url = videoUrl + } else if (imageUrl) { + body.image_url = imageUrl + } + + const containerId = await createMediaContainer(params.accessToken, igUserId, body) + // Images and videos both need FINISHED before media_publish (code 9007 otherwise). + const { statusCode } = await waitForContainerReady(params.accessToken, containerId) + const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) + + return { + success: true, + output: { containerId, mediaId, statusCode }, + } + } catch (error) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: error instanceof Error ? error.message : 'Failed to publish story', + } + } + }, + + transformResponse: async (response) => { + if (!response.ok) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: `Failed to resolve Instagram account: ${response.statusText}`, + } + } + return { + success: true, + output: { containerId: null, mediaId: null, statusCode: null }, + } + }, + + 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 }, + }, +} diff --git a/apps/sim/tools/instagram/publish_video.ts b/apps/sim/tools/instagram/publish_video.ts new file mode 100644 index 00000000000..64862649e48 --- /dev/null +++ b/apps/sim/tools/instagram/publish_video.ts @@ -0,0 +1,121 @@ +import type { InstagramPublishResponse, InstagramPublishVideoParams } from '@/tools/instagram/types' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} 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 a public 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)', + }, + videoUrl: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Public HTTPS URL of the video file', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post caption', + }, + coverUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional public JPEG cover image URL', + }, + }, + + request: { + url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + method: 'GET', + headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + }, + + postProcess: async (result, params) => { + if (!result.success) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: result.error || 'Failed to resolve Instagram account', + } + } + + try { + const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) + // Meta deprecated media_type=VIDEO for standalone posts (Nov 2023); + // feed videos must be published as REELS shared to the feed. + const body: Record = { + media_type: 'REELS', + video_url: params.videoUrl, + share_to_feed: true, + } + if (params.caption) body.caption = params.caption + if (params.coverUrl) body.cover_url = params.coverUrl + + const containerId = await createMediaContainer(params.accessToken, igUserId, body) + const { statusCode } = await waitForContainerReady(params.accessToken, containerId) + const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) + + return { + success: true, + output: { containerId, mediaId, statusCode }, + } + } catch (error) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: error instanceof Error ? error.message : 'Failed to publish video', + } + } + }, + + transformResponse: async (response) => { + if (!response.ok) { + return { + success: false, + output: { containerId: null, mediaId: null, statusCode: null }, + error: `Failed to resolve Instagram account: ${response.statusText}`, + } + } + return { + success: true, + output: { containerId: null, mediaId: null, statusCode: null }, + } + }, + + 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 }, + }, +} 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..f5192873af3 --- /dev/null +++ b/apps/sim/tools/instagram/reply_to_comment.ts @@ -0,0 +1,69 @@ +import type { + InstagramReplyToCommentParams, + InstagramReplyToCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, 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: { + url: (params) => graphUrl(`/${params.commentId.trim()}/replies`), + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + body: (params) => ({ message: params.message }), + }, + + 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: data.id ?? null }, + } + }, + + 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..d91f2fe67c3 --- /dev/null +++ b/apps/sim/tools/instagram/send_text_message.ts @@ -0,0 +1,120 @@ +import type { + InstagramSendTextMessageParams, + InstagramSendTextMessageResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError, resolveIgUserId } 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: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + method: 'GET', + headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + }, + + postProcess: async (result, params) => { + if (!result.success) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: result.error || 'Failed to resolve Instagram account', + } + } + + try { + const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) + const response = await fetch(graphUrl(`/${igUserId}/messages`), { + method: 'POST', + headers: bearerHeaders(params.accessToken), + body: JSON.stringify({ + recipient: { id: params.recipientId.trim() }, + message: { text: params.message }, + }), + }) + + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: params.recipientId.trim() }, + error: await readGraphError(response), + } + } + + const data = (await response.json()) as { + message_id?: string + recipient_id?: string + } + + return { + success: true, + output: { + messageId: data.message_id ?? null, + recipientId: data.recipient_id ?? params.recipientId.trim() ?? null, + }, + } + } catch (error) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: error instanceof Error ? error.message : 'Failed to send message', + } + } + }, + + transformResponse: async (response) => { + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: `Failed to resolve Instagram account: ${response.statusText}`, + } + } + return { success: true, output: { messageId: null, recipientId: 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..70f9ce4842e --- /dev/null +++ b/apps/sim/tools/instagram/types.ts @@ -0,0 +1,343 @@ +import type { ToolResponse } from '@/tools/types' + +export interface InstagramAccessParams { + accessToken: string + igUserId?: string +} + +export interface InstagramGetProfileParams extends InstagramAccessParams {} + +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 + fields?: 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 + fields?: 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 + }> + } +} + +export interface InstagramPublishImageParams extends InstagramAccessParams { + imageUrl: string + caption?: string + altText?: string + isAiGenerated?: boolean +} + +export interface InstagramPublishVideoParams extends InstagramAccessParams { + videoUrl: string + caption?: string + coverUrl?: string +} + +export interface InstagramPublishReelParams extends InstagramAccessParams { + videoUrl: string + caption?: string + coverUrl?: string + shareToFeed?: boolean + thumbOffset?: number +} + +export interface InstagramPublishStoryParams extends InstagramAccessParams { + imageUrl?: string + videoUrl?: string +} + +export interface InstagramPublishCarouselParams extends InstagramAccessParams { + /** Comma-separated public image/video URLs (max 10). Prefix video URLs with video: */ + mediaUrls: string + 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 + } +} + +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 + fromId: string | null + fromUsername: string | null + message: 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 +} + +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 + values: 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..5896c21b2f9 --- /dev/null +++ b/apps/sim/tools/instagram/utils.ts @@ -0,0 +1,165 @@ +import { sleep } from '@sim/utils/helpers' + +export const INSTAGRAM_GRAPH_VERSION = 'v22.0' +export const INSTAGRAM_GRAPH_BASE = `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}` + +export function bearerHeaders(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() +} + +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,id' }), { + headers: bearerHeaders(accessToken), + }) + + if (!response.ok) { + throw new Error(`Failed to resolve Instagram user id: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { user_id?: string; id?: string } + const resolved = data.user_id || data.id + if (!resolved) { + throw new Error('Instagram /me response did not include a user id') + } + return resolved +} + +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, + } +} + +const POLL_INTERVAL_MS = 8_000 +const POLL_MAX_ATTEMPTS = 40 // ~5 minutes + +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}` : ''}` + ) + } + + await sleep(POLL_INTERVAL_MS) + } + + throw new Error(`Timed out waiting for Instagram container ${containerId} to finish processing`) +} + +export async function createMediaContainer( + accessToken: string, + igUserId: string, + body: Record +): Promise { + const response = await fetch(graphUrl(`/${igUserId}/media`), { + method: 'POST', + headers: bearerHeaders(accessToken), + body: JSON.stringify(body), + }) + + if (!response.ok) { + throw new Error(`Failed to create media container: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { id?: string } + if (!data.id) { + throw new Error('Create media container response missing id') + } + return data.id +} + +export async function publishMediaContainer( + accessToken: string, + igUserId: string, + creationId: string +): Promise { + const response = await fetch(graphUrl(`/${igUserId}/media_publish`), { + method: 'POST', + headers: bearerHeaders(accessToken), + body: JSON.stringify({ creation_id: creationId }), + }) + + if (!response.ok) { + throw new Error(`Failed to publish media: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { id?: string } + if (!data.id) { + throw new Error('Publish media response missing id') + } + return data.id +} + +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 4fcda0f1cd4..6c2dc6d891d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1825,6 +1825,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, @@ -4833,6 +4858,29 @@ export const tools: Record = { instantly_pause_campaign: instantlyPauseCampaignTool, instantly_reply_to_email: instantlyReplyToEmailTool, instantly_update_lead_interest_status: instantlyUpdateLeadInterestStatusTool, + 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, jina_read_url: jinaReadUrlTool, jina_search: jinaSearchTool, ketch_get_consent: ketchGetConsentTool, From 5fa0c846be4d39a2bae1d701fbda26f48be8f42a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 10 Jul 2026 11:23:51 -0700 Subject: [PATCH 2/8] feat(instagram): add Gmail-style media uploads for publish ops Resolve UserFiles to Meta-fetchable presigned HTTPS URLs (600s TTL) via internal publish routes, and fix OAuth scope storage plus connect-draft wiring so Instagram Login publishing is testable end-to-end. Co-authored-by: Cursor --- .../docs/en/integrations/instagram.mdx | 31 +- .../app/api/auth/instagram/authorize/route.ts | 16 +- .../app/api/auth/oauth2/authorize/route.ts | 66 +-- .../auth/oauth2/callback/instagram/route.ts | 31 +- .../tools/instagram/publish-carousel/route.ts | 104 ++++ .../tools/instagram/publish-image/route.ts | 86 +++ .../api/tools/instagram/publish-reel/route.ts | 114 ++++ .../tools/instagram/publish-story/route.ts | 88 ++++ .../tools/instagram/publish-video/route.ts | 112 ++++ apps/sim/blocks/blocks/instagram.ts | 267 ++++++++-- .../lib/api/contracts/oauth-connections.ts | 1 + apps/sim/lib/api/contracts/tools/instagram.ts | 121 +++++ apps/sim/lib/copilot/tools/handlers/oauth.ts | 6 + apps/sim/lib/credentials/connect-draft.ts | 63 +++ apps/sim/lib/integrations/instagram.ts | 2 + .../integrations/instagram/resolve-media.ts | 492 ++++++++++++++++++ apps/sim/lib/integrations/integrations.json | 12 +- .../tools/instagram/get_account_insights.ts | 7 + .../instagram/get_conversation_messages.ts | 19 +- .../sim/tools/instagram/get_media_insights.ts | 5 +- apps/sim/tools/instagram/private_reply.ts | 24 +- apps/sim/tools/instagram/publish_carousel.ts | 120 +---- apps/sim/tools/instagram/publish_image.ts | 81 +-- apps/sim/tools/instagram/publish_reel.ts | 84 +-- apps/sim/tools/instagram/publish_story.ts | 101 +--- apps/sim/tools/instagram/publish_video.ts | 82 +-- apps/sim/tools/instagram/send_text_message.ts | 2 +- apps/sim/tools/instagram/types.ts | 28 +- apps/sim/tools/instagram/utils.ts | 13 +- apps/sim/tools/registry.ts | 32 +- 30 files changed, 1667 insertions(+), 543 deletions(-) create mode 100644 apps/sim/app/api/tools/instagram/publish-carousel/route.ts create mode 100644 apps/sim/app/api/tools/instagram/publish-image/route.ts create mode 100644 apps/sim/app/api/tools/instagram/publish-reel/route.ts create mode 100644 apps/sim/app/api/tools/instagram/publish-story/route.ts create mode 100644 apps/sim/app/api/tools/instagram/publish-video/route.ts create mode 100644 apps/sim/lib/api/contracts/tools/instagram.ts create mode 100644 apps/sim/lib/credentials/connect-draft.ts create mode 100644 apps/sim/lib/integrations/instagram.ts create mode 100644 apps/sim/lib/integrations/instagram/resolve-media.ts diff --git a/apps/docs/content/docs/en/integrations/instagram.mdx b/apps/docs/content/docs/en/integrations/instagram.mdx index 7458da66b08..5c7eb79d4a4 100644 --- a/apps/docs/content/docs/en/integrations/instagram.mdx +++ b/apps/docs/content/docs/en/integrations/instagram.mdx @@ -105,14 +105,14 @@ List active stories on the Instagram professional account ### `instagram_publish_image` -Create and publish a single JPEG image post from a public URL +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\) | -| `imageUrl` | string | Yes | Public HTTPS URL of a JPEG image \(Meta will download it\) | +| `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 | @@ -127,16 +127,16 @@ Create and publish a single JPEG image post from a public URL ### `instagram_publish_video` -Create and publish a feed video from a public URL (published as a Reel shared to the feed; polls until ready) +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\) | -| `videoUrl` | string | Yes | Public HTTPS URL of the video file | +| `video` | file | Yes | Video file or public HTTPS URL | | `caption` | string | No | Post caption | -| `coverUrl` | string | No | Optional public JPEG cover image URL | +| `cover` | file | No | Optional JPEG cover image file or public HTTPS URL | #### Output @@ -148,16 +148,16 @@ Create and publish a feed video from a public URL (published as a Reel shared to ### `instagram_publish_reel` -Create and publish a Reel from a public video URL (polls until ready) +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\) | -| `videoUrl` | string | Yes | Public HTTPS URL of the Reel video | +| `video` | file | Yes | Reel video file or public HTTPS URL | | `caption` | string | No | Reel caption | -| `coverUrl` | string | No | Optional public JPEG cover image URL | +| `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 | @@ -171,15 +171,14 @@ Create and publish a Reel from a public video URL (polls until ready) ### `instagram_publish_story` -Publish an image or video story from a public URL +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\) | -| `imageUrl` | string | No | Public HTTPS JPEG URL for an image story | -| `videoUrl` | string | No | Public HTTPS video URL for a video story | +| `media` | file | Yes | JPEG image or MP4/MOV video file, or a public HTTPS URL | #### Output @@ -191,14 +190,14 @@ Publish an image or video story from a public URL ### `instagram_publish_carousel` -Publish a carousel of up to 10 images/videos from public URLs +Publish a carousel of up to 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\) | -| `mediaUrls` | string | Yes | Comma-separated public media URLs \(max 10\). Prefix video URLs with video: \(e.g. https://.../a.jpg,video:https://.../b.mp4\) | +| `media` | file[] | Yes | Up to 10 media files, or a comma-separated public URL string \(prefix videos with video:\) | | `caption` | string | No | Carousel caption | #### Output @@ -347,6 +346,7 @@ Send a private Direct message reply to a commenter (one per commenter) | Parameter | Type | Description | | --------- | ---- | ----------- | | `messageId` | string | Sent message id | +| `recipientId` | string | Instagram-scoped recipient id | ### `instagram_list_conversations` @@ -382,7 +382,7 @@ List recent message ids/content in a conversation (only the last ~20 messages ar | Parameter | Type | Description | | --------- | ---- | ----------- | | `conversationId` | string | Conversation id | -| `messages` | json | Messages \(id, createdTime, fromId, fromUsername, message\) | +| `messages` | json | Message references \(id, createdTime\). Use Get Message for sender, recipient, and text. | ### `instagram_get_message` @@ -439,6 +439,7 @@ Get insights metrics for the Instagram professional account | `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 @@ -461,6 +462,6 @@ Get insights metrics for a specific Instagram media object | Parameter | Type | Description | | --------- | ---- | ----------- | -| `insights` | json | Media insight metrics \(name, period, values\) | +| `insights` | json | Media insight metrics \(name, period, title, description, values, totalValue\) | diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index da610cc4067..85b4437748a 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -8,7 +8,9 @@ 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') @@ -28,6 +30,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => { 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) { @@ -56,7 +71,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { path: INSTAGRAM_STATE_COOKIE_PATH, }) - const returnUrl = parsed.data.query.returnUrl if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { httpOnly: true, 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 index 5c144a023f1..17a40f7ca45 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -11,6 +11,7 @@ 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' @@ -21,12 +22,12 @@ 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_GRAPH_VERSION = 'v22.0' - interface ShortLivedTokenPayload { access_token?: string user_id?: string | number - permissions?: string + // 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 { @@ -184,7 +185,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const expiresIn = longLivedBody.expires_in ?? 5184000 const profileResponse = await fetch( - `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}/me?fields=user_id,username,name,account_type,profile_picture_url&access_token=${encodeURIComponent(longLivedToken)}` + `${INSTAGRAM_GRAPH_BASE}/me?fields=user_id,username,name,account_type,profile_picture_url&access_token=${encodeURIComponent(longLivedToken)}` ) if (!profileResponse.ok) { @@ -221,14 +222,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } + 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 = - typeof shortLived.permissions === 'string' && shortLived.permissions.length > 0 - ? shortLived.permissions - .split(',') - .map((s) => s.trim()) - .filter(Boolean) + grantedPermissions.length > 0 + ? grantedPermissions : getCanonicalScopesForProvider('instagram') - const scope = permissions.join(',') + // 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) 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..241b17a3e39 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -0,0 +1,104 @@ +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' +/** + * Carousel children are created and polled sequentially; allow headroom beyond a + * single container's five-minute window. + */ +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) + 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 + } + + const childId = await createMediaContainer(body.accessToken, igUserId, childBody) + await waitForContainerReady(body.accessToken, childId) + childIds.push(childId) + } + + 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/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index 905442449f0..6d99b25adda 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -2,8 +2,42 @@ 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', @@ -86,37 +120,140 @@ export const InstagramBlock: BlockConfig = { required: true, }, + // Publish Image — JPEG upload / file ref / public HTTPS URL + { + id: 'imageUpload', + title: 'Image', + type: 'file-upload', + canonicalParamId: 'image', + placeholder: 'Upload a JPEG image to publish', + acceptedTypes: '.jpg,.jpeg,image/jpeg', + condition: { field: 'operation', value: 'instagram_publish_image' }, + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'instagram_publish_image' }, + }, { - id: 'imageUrl', - title: 'Image URL', + id: 'imageRef', + title: 'Image', type: 'short-input', - placeholder: 'https://example.com/image.jpg', + canonicalParamId: 'image', + placeholder: 'Reference files from previous blocks (or paste a public HTTPS JPEG URL)', + 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', condition: { field: 'operation', - value: ['instagram_publish_image', 'instagram_publish_story'], + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'basic', + multiple: false, + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], }, - required: { field: 'operation', value: 'instagram_publish_image' }, }, { - id: 'videoUrl', - title: 'Video URL', + id: 'videoRef', + title: 'Video', type: 'short-input', - placeholder: 'https://example.com/video.mp4 (for stories: use image OR video URL, not both)', + canonicalParamId: 'video', + placeholder: 'Reference files from previous blocks (or paste a public HTTPS video URL)', condition: { field: 'operation', - value: ['instagram_publish_video', 'instagram_publish_reel', 'instagram_publish_story'], + value: ['instagram_publish_video', 'instagram_publish_reel'], }, + mode: 'advanced', required: { field: 'operation', value: ['instagram_publish_video', 'instagram_publish_reel'], }, }, { - id: 'mediaUrls', - title: 'Media URLs', + id: 'coverUpload', + title: 'Cover Image', + type: 'file-upload', + canonicalParamId: 'cover', + placeholder: 'Upload a JPEG cover image', + acceptedTypes: '.jpg,.jpeg,image/jpeg', + 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 (or paste a public HTTPS JPEG URL)', + 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', + 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 (or paste a public HTTPS URL)', + 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 up to 10 images/videos to publish', + acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', + 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', - placeholder: 'Comma-separated public URLs (prefix videos with video:)', + canonicalParamId: 'carouselMedia', + placeholder: + 'Reference files from previous blocks, or paste comma-separated public HTTPS URLs (prefix videos with video:)', condition: { field: 'operation', value: 'instagram_publish_carousel' }, + mode: 'advanced', required: { field: 'operation', value: 'instagram_publish_carousel' }, wandConfig: { enabled: true, @@ -131,6 +268,7 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, placeholder: 'Describe the carousel media URLs to include...', }, }, + { id: 'caption', title: 'Caption', @@ -166,17 +304,6 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', value: 'instagram_publish_image' }, }, - { - id: 'coverUrl', - title: 'Cover URL', - type: 'short-input', - placeholder: 'https://example.com/cover.jpg', - mode: 'advanced', - condition: { - field: 'operation', - value: ['instagram_publish_video', 'instagram_publish_reel'], - }, - }, { id: 'shareToFeed', title: 'Share to Feed', @@ -340,7 +467,7 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, 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,profile_views,follower_count,website_clicks +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. @@ -354,10 +481,7 @@ Return ONLY the comma-separated metric names - no explanations, no extra text.`, 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' }, @@ -422,6 +546,21 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, 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', @@ -483,14 +622,51 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, config: { tool: (params) => params.operation, params: (params) => { - const { oauthCredential, ...rest } = 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) @@ -513,13 +689,27 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, inputs: { operation: { type: 'string', description: 'Operation to perform' }, oauthCredential: { type: 'string', description: 'Instagram OAuth credential' }, - imageUrl: { type: 'string', description: 'Public HTTPS image URL' }, - videoUrl: { type: 'string', description: 'Public HTTPS video URL' }, - mediaUrls: { type: 'string', description: 'Comma-separated media URLs for carousel' }, + 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: up to 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' }, - coverUrl: { type: 'string', description: 'Cover image URL for video or Reel' }, shareToFeed: { type: 'boolean', description: 'Also share Reel to the main feed' }, thumbOffset: { type: 'number', @@ -540,6 +730,10 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, 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' }, @@ -595,7 +789,8 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, conversationId: { type: 'string', description: 'Conversation id' }, messages: { type: 'json', - description: 'Messages (id, createdTime, fromId, fromUsername, message)', + 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' }, @@ -640,7 +835,7 @@ export const InstagramBlockMeta = { icon: InstagramIcon, title: 'Instagram DM reply assistant', prompt: - 'Build a workflow that lists Instagram Direct conversations, fetches unread messages, drafts helpful replies with an agent, and sends text messages back to the recipient while logging the thread to a support table.', + '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'], @@ -661,7 +856,7 @@ export const InstagramBlockMeta = { 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. Confirm a public HTTPS JPEG URL for the image 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.', + '# 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', @@ -681,7 +876,7 @@ export const InstagramBlockMeta = { 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 trends, run Get Account Insights with comma-separated metrics and a period such as day, week, or days_28.\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.', + '# 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/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index d6f13ffad93..8c110313007 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -193,6 +193,7 @@ export const trelloCallbackContract = defineRouteContract({ export const instagramAuthorizeQuerySchema = z.object({ returnUrl: z.string().optional(), + workspaceId: workspaceIdSchema.optional(), }) export const authorizeInstagramContract = defineRouteContract({ 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/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/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..3fcb5478cab --- /dev/null +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -0,0 +1,492 @@ +import type { Logger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' +import { StorageService } from '@/lib/uploads' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { + extractStorageKey, + getFileExtension, + getMimeTypeFromExtension, + isInternalFileUrl, + processSingleFileToUserFile, + type RawFileInput, + resolveFileType, + resolveTrustedFileContext, +} from '@/lib/uploads/utils/file-utils' +import { verifyFileAccess } from '@/app/api/files/authorization' +import type { UserFile } from '@/executor/types' + +/** 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']) + +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() + const isJpeg = !mime || JPEG_MIME.has(mime) + const isVideoMime = !mime || VIDEO_MIME.has(mime) + + 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)})` + } + if (!isJpeg && mime) { + return `${label} must be a JPEG image` + } + return null + } + + // video + if (mime && !VIDEO_MIME.has(mime)) { + return `${label} must be an MP4 or MOV video (got ${mime})` + } + if (!isVideoMime && mime) { + return `${label} must be an MP4 or MOV video` + } + + 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 isPublicHttpsUrl(value: string): boolean { + return (value.startsWith('https://') || value.startsWith('http://')) && !isInternalFileUrl(value) +} + +async function resolvePublicUrl( + url: string, + requestId: string, + logger: Logger +): Promise { + if (!url.startsWith('https://')) { + return { + error: { + status: 400, + message: 'Instagram media URLs must use HTTPS so Meta can download them', + }, + } + } + + const validation = await validateUrlWithDNS(url, 'instagramMediaUrl') + if (!validation.isValid) { + logger.warn(`[${requestId}] Invalid Instagram media URL`, { error: validation.error }) + return { error: { status: 400, message: validation.error || 'Invalid media URL' } } + } + + return { media: { url, kind: 'image' } } +} + +async function presignUserFile( + userFile: UserFile, + userId: string, + requestId: string, + logger: Logger +): Promise { + if (!hasCloudStorage()) { + return { + error: { + status: 400, + message: + 'Cloud storage is required to publish uploaded Instagram media. Configure S3 or Blob storage, or paste a public HTTPS URL instead.', + }, + } + } + + let key = userFile.key + if (!key && userFile.url && isInternalFileUrl(userFile.url)) { + key = extractStorageKey(userFile.url) + } + + if (!key) { + if (userFile.url && isPublicHttpsUrl(userFile.url)) { + return resolvePublicUrl(userFile.url, requestId, logger) + } + return { + error: { + status: 400, + message: 'Uploaded file is missing a storage key and cannot be shared with Instagram', + }, + } + } + + const context = resolveTrustedFileContext(key, userFile.context) + const hasAccess = await verifyFileAccess(key, userId, undefined, context, false) + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized Instagram media presign attempt`, { + userId, + key, + context, + }) + return { error: { status: 404, message: 'File not found' } } + } + + try { + const url = await StorageService.generatePresignedDownloadUrl( + key, + context, + INSTAGRAM_MEDIA_URL_TTL_SECONDS + ) + logger.info(`[${requestId}] Generated Instagram media presigned URL`, { + key, + ttlSeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS, + }) + return { + media: { + url, + kind: 'image', + mimeType: userFile.type, + size: userFile.size, + name: userFile.name, + }, + } + } catch (error) { + logger.error(`[${requestId}] Failed to generate Instagram media URL:`, error) + return { + error: { + status: 500, + message: getErrorMessage(error, 'Failed to generate a Meta-fetchable media URL'), + }, + } + } +} + +/** + * Resolve a UserFile, internal serve path, or public HTTPS URL into a Meta-fetchable HTTPS URL. + * Uploaded files are re-presigned with a 600s TTL so Meta can curl them during container processing. + */ +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 {} + } + + let resolved: ResolveInstagramMediaResult + + if (typeof input === 'string') { + const trimmed = input.trim() + if (!trimmed) { + if (required) { + return { error: { status: 400, message: `${label} is required` } } + } + return {} + } + + if (isInternalFileUrl(trimmed)) { + if (!hasCloudStorage()) { + return { + error: { + status: 400, + message: + 'Cloud storage is required to publish uploaded Instagram media. Configure S3 or Blob storage, or paste a public HTTPS URL instead.', + }, + } + } + const key = extractStorageKey(trimmed) + const context = resolveTrustedFileContext(key) + const hasAccess = await verifyFileAccess(key, userId, undefined, context, false) + if (!hasAccess) { + return { error: { status: 404, message: 'File not found' } } + } + try { + const url = await StorageService.generatePresignedDownloadUrl( + key, + context, + INSTAGRAM_MEDIA_URL_TTL_SECONDS + ) + resolved = { media: { url, kind: 'image' } } + } catch (error) { + logger.error(`[${requestId}] Failed to presign internal Instagram media path:`, error) + return { + error: { + status: 500, + message: getErrorMessage(error, 'Failed to generate a Meta-fetchable media URL'), + }, + } + } + } else if (isPublicHttpsUrl(trimmed) || trimmed.startsWith('http://')) { + resolved = await resolvePublicUrl(trimmed, requestId, logger) + } else { + return { + error: { + status: 400, + message: `${label} must be an uploaded file or a public HTTPS URL`, + }, + } + } + } else if (typeof input === 'object') { + let userFile: UserFile + try { + userFile = processSingleFileToUserFile(input as RawFileInput, requestId, logger) + } catch (error) { + return { + error: { + status: 400, + message: getErrorMessage(error, `Failed to process ${label.toLowerCase()}`), + }, + } + } + resolved = await presignUserFile(userFile, userId, requestId, logger) + if (resolved.media) { + resolved.media.mimeType = resolveFileType({ + type: userFile.type || '', + name: userFile.name, + }) + resolved.media.size = userFile.size + resolved.media.name = userFile.name + } + } else { + return { error: { status: 400, message: `${label} must be a file or URL string` } } + } + + if (resolved.error || !resolved.media) { + return resolved + } + + const mimeType = + resolved.media.mimeType || + getMimeTypeFromExtension(extensionFromUrlOrName(resolved.media.name || resolved.media.url)) + const extension = extensionFromUrlOrName(resolved.media.name || resolved.media.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 + } + + // Prefer extension hints when MIME is generic + 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, + resolved.media.size, + label + ) + if (constraintError) { + return { error: { status: 400, message: constraintError } } + } + + // Soft extension check for public URLs without MIME + 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: { + ...resolved.media, + kind, + 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' } } + } + + // JSON-serialized file array from advanced mode + 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 === 0) { + return { error: { status: 400, message: 'Provide at least one carousel media item' } } + } + if (entries.length > 10) { + return { error: { status: 400, message: 'Carousels support a maximum of 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 + // Legacy comma-separated URLs: plain = image, `video:` prefix = video + 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}`, + }, + } + } + if (isVideoPrefixed) { + result.media.kind = 'video' + } else { + result.media.kind = 'image' + } + items.push(result.media) + } + + return { items } + } + + const list = Array.isArray(input) ? input : [input] + if (list.length === 0) { + return { error: { status: 400, message: 'Provide at least one carousel media item' } } + } + if (list.length > 10) { + return { error: { status: 400, message: 'Carousels support a maximum of 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 e9a1cbb8bd4..a2af2efcd43 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-09", + "updatedAt": "2026-07-10", "integrations": [ { "type": "onepassword", @@ -8958,23 +8958,23 @@ }, { "name": "Publish Image", - "description": "Create and publish a single JPEG image post from a public URL" + "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 a public URL (published as a Reel shared to the feed; polls until ready)" + "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 a public video URL (polls until ready)" + "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 a public URL" + "description": "Publish an image or video story from an uploaded file or public HTTPS URL" }, { "name": "Publish Carousel", - "description": "Publish a carousel of up to 10 images/videos from public URLs" + "description": "Publish a carousel of up to 10 images/videos from uploaded files or public HTTPS URLs" }, { "name": "Get Container Status", diff --git a/apps/sim/tools/instagram/get_account_insights.ts b/apps/sim/tools/instagram/get_account_insights.ts index 26885cb588c..0b52d4fa429 100644 --- a/apps/sim/tools/instagram/get_account_insights.ts +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -74,6 +74,12 @@ export const instagramGetAccountInsightsTool: ToolConfig< 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: { @@ -86,6 +92,7 @@ export const instagramGetAccountInsightsTool: ToolConfig< until: params.until?.trim() || undefined, metric_type: params.metricType?.trim() || undefined, breakdown: params.breakdown?.trim() || undefined, + timeframe: params.timeframe?.trim() || undefined, }) }, method: 'GET', diff --git a/apps/sim/tools/instagram/get_conversation_messages.ts b/apps/sim/tools/instagram/get_conversation_messages.ts index 0c4f4a342d0..a1de2e7e7bc 100644 --- a/apps/sim/tools/instagram/get_conversation_messages.ts +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -38,7 +38,7 @@ export const instagramGetConversationMessagesTool: ToolConfig< request: { url: (params) => graphUrl(`/${params.conversationId.trim()}`, { - fields: 'messages{id,created_time,from,to,message}', + fields: 'messages', }), method: 'GET', headers: (params) => bearerHeaders(params.accessToken), @@ -63,16 +63,10 @@ export const instagramGetConversationMessagesTool: ToolConfig< success: true, output: { conversationId: params?.conversationId ?? data.id ?? '', - messages: items.map((item: Record) => { - const from = item.from as { id?: string; username?: string } | undefined - return { - id: String(item.id ?? ''), - createdTime: (item.created_time as string | undefined) ?? null, - fromId: from?.id ?? null, - fromUsername: from?.username ?? null, - message: (item.message as string | undefined) ?? null, - } - }), + messages: items.map((item: Record) => ({ + id: String(item.id ?? ''), + createdTime: (item.created_time as string | undefined) ?? null, + })), }, } }, @@ -81,7 +75,8 @@ export const instagramGetConversationMessagesTool: ToolConfig< conversationId: { type: 'string', description: 'Conversation id' }, messages: { type: 'json', - description: 'Messages (id, createdTime, fromId, fromUsername, message)', + description: + 'Message references (id, createdTime). Use Get Message for sender, recipient, and text.', }, }, } diff --git a/apps/sim/tools/instagram/get_media_insights.ts b/apps/sim/tools/instagram/get_media_insights.ts index a41ab0f418b..ef398c4159a 100644 --- a/apps/sim/tools/instagram/get_media_insights.ts +++ b/apps/sim/tools/instagram/get_media_insights.ts @@ -73,7 +73,10 @@ export const instagramGetMediaInsightsTool: ToolConfig< 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, })), }, } @@ -82,7 +85,7 @@ export const instagramGetMediaInsightsTool: ToolConfig< outputs: { insights: { type: 'json', - description: 'Media insight metrics (name, period, values)', + description: 'Media insight metrics (name, period, title, description, values, totalValue)', }, }, } diff --git a/apps/sim/tools/instagram/private_reply.ts b/apps/sim/tools/instagram/private_reply.ts index e9bea98ac7d..7f29676d5f2 100644 --- a/apps/sim/tools/instagram/private_reply.ts +++ b/apps/sim/tools/instagram/private_reply.ts @@ -47,7 +47,7 @@ export const instagramPrivateReplyTool: ToolConfig< }, request: { - url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + url: () => graphUrl('/me', { fields: 'user_id' }), method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), }, @@ -56,7 +56,7 @@ export const instagramPrivateReplyTool: ToolConfig< if (!result.success) { return { success: false, - output: { messageId: null }, + output: { messageId: null, recipientId: null }, error: result.error || 'Failed to resolve Instagram account', } } @@ -75,20 +75,23 @@ export const instagramPrivateReplyTool: ToolConfig< if (!response.ok) { return { success: false, - output: { messageId: null }, + output: { messageId: null, recipientId: null }, error: await readGraphError(response), } } - const data = (await response.json()) as { message_id?: string } + const data = (await response.json()) as { message_id?: string; recipient_id?: string } return { success: true, - output: { messageId: data.message_id ?? null }, + output: { + messageId: data.message_id ?? null, + recipientId: data.recipient_id ?? null, + }, } } catch (error) { return { success: false, - output: { messageId: null }, + output: { messageId: null, recipientId: null }, error: error instanceof Error ? error.message : 'Failed to send private reply', } } @@ -98,14 +101,19 @@ export const instagramPrivateReplyTool: ToolConfig< if (!response.ok) { return { success: false, - output: { messageId: null }, + output: { messageId: null, recipientId: null }, error: `Failed to resolve Instagram account: ${response.statusText}`, } } - return { success: true, output: { messageId: null } } + return { success: true, output: { messageId: null, recipientId: null } } }, 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 index 934d6921993..ac0db640328 100644 --- a/apps/sim/tools/instagram/publish_carousel.ts +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -2,35 +2,16 @@ import type { InstagramPublishCarouselParams, InstagramPublishResponse, } from '@/tools/instagram/types' -import { - createMediaContainer, - parseCommaSeparated, - publishMediaContainer, - resolveIgUserId, - waitForContainerReady, -} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' -/** - * Parse media URL entries. Prefix with `video:` for video items, otherwise treated as images. - * Example: `https://cdn.example.com/a.jpg,video:https://cdn.example.com/b.mp4` - */ -function parseCarouselItems(mediaUrls: string): Array<{ type: 'image' | 'video'; url: string }> { - return parseCommaSeparated(mediaUrls).map((entry) => { - if (entry.toLowerCase().startsWith('video:')) { - return { type: 'video' as const, url: entry.slice('video:'.length).trim() } - } - return { type: 'image' as const, url: entry } - }) -} - export const instagramPublishCarouselTool: ToolConfig< InstagramPublishCarouselParams, InstagramPublishResponse > = { id: 'instagram_publish_carousel', name: 'Instagram Publish Carousel', - description: 'Publish a carousel of up to 10 images/videos from public URLs', + description: + 'Publish a carousel of up to 10 images/videos from uploaded files or public HTTPS URLs', version: '1.0.0', oauth: { @@ -51,12 +32,12 @@ export const instagramPublishCarouselTool: ToolConfig< visibility: 'user-or-llm', description: 'Instagram professional account user id (defaults to /me)', }, - mediaUrls: { - type: 'string', + media: { + type: 'file[]', required: true, visibility: 'user-or-llm', description: - 'Comma-separated public media URLs (max 10). Prefix video URLs with video: (e.g. https://.../a.jpg,video:https://.../b.mp4)', + 'Up to 10 media files, or a comma-separated public URL string (prefix videos with video:)', }, caption: { type: 'string', @@ -67,90 +48,31 @@ export const instagramPublishCarouselTool: ToolConfig< }, request: { - url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', - method: 'GET', - headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), - }, - - postProcess: async (result, params) => { - if (!result.success) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: result.error || 'Failed to resolve Instagram account', - } - } - - const items = parseCarouselItems(params.mediaUrls) - if (items.length === 0) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: 'Provide at least one media URL', - } - } - if (items.length > 10) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: 'Carousels support a maximum of 10 items', - } - } - - try { - const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) - const childIds: string[] = [] - - for (const item of items) { - const childBody: Record = { - is_carousel_item: true, - } - if (item.type === 'video') { - childBody.media_type = 'VIDEO' - childBody.video_url = item.url - } else { - childBody.image_url = item.url - } - - const childId = await createMediaContainer(params.accessToken, igUserId, childBody) - await waitForContainerReady(params.accessToken, childId) - childIds.push(childId) - } - - const parentBody: Record = { - media_type: 'CAROUSEL', - children: childIds.join(','), - } - if (params.caption) parentBody.caption = params.caption - - const containerId = await createMediaContainer(params.accessToken, igUserId, parentBody) - const { statusCode } = await waitForContainerReady(params.accessToken, containerId) - const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) - - return { - success: true, - output: { containerId, mediaId, statusCode }, - } - } catch (error) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: error instanceof Error ? error.message : 'Failed to publish carousel', - } - } + 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: async (response) => { - if (!response.ok) { + const data = await response.json() + if (!response.ok || data.success === false) { return { success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: `Failed to resolve Instagram account: ${response.statusText}`, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, + error: data.error || 'Failed to publish carousel', } } return { success: true, - output: { containerId: null, mediaId: null, statusCode: null }, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, } }, diff --git a/apps/sim/tools/instagram/publish_image.ts b/apps/sim/tools/instagram/publish_image.ts index 472ba248c2a..edf03061fbe 100644 --- a/apps/sim/tools/instagram/publish_image.ts +++ b/apps/sim/tools/instagram/publish_image.ts @@ -1,10 +1,4 @@ import type { InstagramPublishImageParams, InstagramPublishResponse } from '@/tools/instagram/types' -import { - createMediaContainer, - publishMediaContainer, - resolveIgUserId, - waitForContainerReady, -} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishImageTool: ToolConfig< @@ -14,7 +8,7 @@ export const instagramPublishImageTool: ToolConfig< id: 'instagram_publish_image', name: 'Instagram Publish Image', description: - 'Create and publish a single JPEG image post from a public URL (polls until the container is ready)', + '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: { @@ -35,11 +29,11 @@ export const instagramPublishImageTool: ToolConfig< visibility: 'user-or-llm', description: 'Instagram professional account user id (defaults to /me)', }, - imageUrl: { - type: 'string', + image: { + type: 'file', required: true, visibility: 'user-or-llm', - description: 'Public HTTPS URL of a JPEG image (Meta will download it)', + description: 'JPEG image file or public HTTPS URL (Meta will download it)', }, caption: { type: 'string', @@ -62,64 +56,33 @@ export const instagramPublishImageTool: ToolConfig< }, request: { - // Dummy request — real work happens in postProcess - url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', - method: 'GET', - headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), - }, - - postProcess: async (result, params) => { - if (!result.success) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: result.error || 'Failed to resolve Instagram account', - } - } - - try { - const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) - const body: Record = { - image_url: params.imageUrl, - } - if (params.caption) body.caption = params.caption - if (params.altText) body.alt_text = params.altText - if (params.isAiGenerated === true) body.is_ai_generated = true - - const containerId = await createMediaContainer(params.accessToken, igUserId, body) - // Meta downloads/processes the image asynchronously; publishing before - // status_code=FINISHED returns "Media ID is not available" (code 9007). - const { statusCode } = await waitForContainerReady(params.accessToken, containerId) - const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) - - return { - success: true, - output: { - containerId, - mediaId, - statusCode, - }, - } - } catch (error) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: error instanceof Error ? error.message : 'Failed to publish image', - } - } + 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: async (response) => { - if (!response.ok) { + const data = await response.json() + if (!response.ok || data.success === false) { return { success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: `Failed to resolve Instagram account: ${response.statusText}`, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, + error: data.error || 'Failed to publish image', } } return { success: true, - output: { containerId: null, mediaId: null, statusCode: null }, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, } }, diff --git a/apps/sim/tools/instagram/publish_reel.ts b/apps/sim/tools/instagram/publish_reel.ts index 2fa96f12dcc..4791aec07b0 100644 --- a/apps/sim/tools/instagram/publish_reel.ts +++ b/apps/sim/tools/instagram/publish_reel.ts @@ -1,10 +1,4 @@ import type { InstagramPublishReelParams, InstagramPublishResponse } from '@/tools/instagram/types' -import { - createMediaContainer, - publishMediaContainer, - resolveIgUserId, - waitForContainerReady, -} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishReelTool: ToolConfig< @@ -13,7 +7,8 @@ export const instagramPublishReelTool: ToolConfig< > = { id: 'instagram_publish_reel', name: 'Instagram Publish Reel', - description: 'Create and publish a Reel from a public video URL (polls until ready)', + description: + 'Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready)', version: '1.0.0', oauth: { @@ -34,11 +29,11 @@ export const instagramPublishReelTool: ToolConfig< visibility: 'user-or-llm', description: 'Instagram professional account user id (defaults to /me)', }, - videoUrl: { - type: 'string', + video: { + type: 'file', required: true, visibility: 'user-or-llm', - description: 'Public HTTPS URL of the Reel video', + description: 'Reel video file or public HTTPS URL', }, caption: { type: 'string', @@ -46,11 +41,11 @@ export const instagramPublishReelTool: ToolConfig< visibility: 'user-or-llm', description: 'Reel caption', }, - coverUrl: { - type: 'string', + cover: { + type: 'file', required: false, visibility: 'user-or-llm', - description: 'Optional public JPEG cover image URL', + description: 'Optional JPEG cover image file or public HTTPS URL', }, shareToFeed: { type: 'boolean', @@ -67,59 +62,34 @@ export const instagramPublishReelTool: ToolConfig< }, request: { - url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', - method: 'GET', - headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), - }, - - postProcess: async (result, params) => { - if (!result.success) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: result.error || 'Failed to resolve Instagram account', - } - } - - try { - const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) - const body: Record = { - media_type: 'REELS', - video_url: params.videoUrl, - } - if (params.caption) body.caption = params.caption - if (params.coverUrl) body.cover_url = params.coverUrl - if (params.shareToFeed !== undefined) body.share_to_feed = params.shareToFeed - if (params.thumbOffset != null) body.thumb_offset = params.thumbOffset - - const containerId = await createMediaContainer(params.accessToken, igUserId, body) - const { statusCode } = await waitForContainerReady(params.accessToken, containerId) - const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) - - return { - success: true, - output: { containerId, mediaId, statusCode }, - } - } catch (error) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: error instanceof Error ? error.message : 'Failed to publish reel', - } - } + 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: async (response) => { - if (!response.ok) { + const data = await response.json() + if (!response.ok || data.success === false) { return { success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: `Failed to resolve Instagram account: ${response.statusText}`, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, + error: data.error || 'Failed to publish reel', } } return { success: true, - output: { containerId: null, mediaId: null, statusCode: null }, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, } }, diff --git a/apps/sim/tools/instagram/publish_story.ts b/apps/sim/tools/instagram/publish_story.ts index 92ae5591fb0..efb6ab28926 100644 --- a/apps/sim/tools/instagram/publish_story.ts +++ b/apps/sim/tools/instagram/publish_story.ts @@ -1,10 +1,4 @@ import type { InstagramPublishResponse, InstagramPublishStoryParams } from '@/tools/instagram/types' -import { - createMediaContainer, - publishMediaContainer, - resolveIgUserId, - waitForContainerReady, -} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishStoryTool: ToolConfig< @@ -13,7 +7,7 @@ export const instagramPublishStoryTool: ToolConfig< > = { id: 'instagram_publish_story', name: 'Instagram Publish Story', - description: 'Publish an image or video story from a public URL', + description: 'Publish an image or video story from an uploaded file or public HTTPS URL', version: '1.0.0', oauth: { @@ -34,94 +28,39 @@ export const instagramPublishStoryTool: ToolConfig< visibility: 'user-or-llm', description: 'Instagram professional account user id (defaults to /me)', }, - imageUrl: { - type: 'string', - required: false, - visibility: 'user-or-llm', - description: 'Public HTTPS JPEG URL for an image story', - }, - videoUrl: { - type: 'string', - required: false, + media: { + type: 'file', + required: true, visibility: 'user-or-llm', - description: 'Public HTTPS video URL for a video story', + description: 'JPEG image or MP4/MOV video file, or a public HTTPS URL', }, }, request: { - url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', - method: 'GET', - headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), - }, - - postProcess: async (result, params) => { - if (!result.success) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: result.error || 'Failed to resolve Instagram account', - } - } - - const imageUrl = params.imageUrl?.trim() || undefined - const videoUrl = params.videoUrl?.trim() || undefined - - if (!imageUrl && !videoUrl) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: 'Provide either imageUrl or videoUrl for a story', - } - } - - if (imageUrl && videoUrl) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: 'Provide only one of imageUrl or videoUrl for a story, not both', - } - } - - try { - const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) - const body: Record = { - media_type: 'STORIES', - } - if (videoUrl) { - body.video_url = videoUrl - } else if (imageUrl) { - body.image_url = imageUrl - } - - const containerId = await createMediaContainer(params.accessToken, igUserId, body) - // Images and videos both need FINISHED before media_publish (code 9007 otherwise). - const { statusCode } = await waitForContainerReady(params.accessToken, containerId) - const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) - - return { - success: true, - output: { containerId, mediaId, statusCode }, - } - } catch (error) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: error instanceof Error ? error.message : 'Failed to publish story', - } - } + 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: async (response) => { - if (!response.ok) { + const data = await response.json() + if (!response.ok || data.success === false) { return { success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: `Failed to resolve Instagram account: ${response.statusText}`, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, + error: data.error || 'Failed to publish story', } } return { success: true, - output: { containerId: null, mediaId: null, statusCode: null }, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, } }, diff --git a/apps/sim/tools/instagram/publish_video.ts b/apps/sim/tools/instagram/publish_video.ts index 64862649e48..579f5c9665c 100644 --- a/apps/sim/tools/instagram/publish_video.ts +++ b/apps/sim/tools/instagram/publish_video.ts @@ -1,10 +1,4 @@ import type { InstagramPublishResponse, InstagramPublishVideoParams } from '@/tools/instagram/types' -import { - createMediaContainer, - publishMediaContainer, - resolveIgUserId, - waitForContainerReady, -} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishVideoTool: ToolConfig< @@ -14,7 +8,7 @@ export const instagramPublishVideoTool: ToolConfig< id: 'instagram_publish_video', name: 'Instagram Publish Video', description: - 'Create and publish a feed video from a public URL (published as a Reel shared to the feed; polls until ready)', + '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: { @@ -35,11 +29,11 @@ export const instagramPublishVideoTool: ToolConfig< visibility: 'user-or-llm', description: 'Instagram professional account user id (defaults to /me)', }, - videoUrl: { - type: 'string', + video: { + type: 'file', required: true, visibility: 'user-or-llm', - description: 'Public HTTPS URL of the video file', + description: 'Video file or public HTTPS URL', }, caption: { type: 'string', @@ -47,69 +41,41 @@ export const instagramPublishVideoTool: ToolConfig< visibility: 'user-or-llm', description: 'Post caption', }, - coverUrl: { - type: 'string', + cover: { + type: 'file', required: false, visibility: 'user-or-llm', - description: 'Optional public JPEG cover image URL', + description: 'Optional JPEG cover image file or public HTTPS URL', }, }, request: { - url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', - method: 'GET', - headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), - }, - - postProcess: async (result, params) => { - if (!result.success) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: result.error || 'Failed to resolve Instagram account', - } - } - - try { - const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) - // Meta deprecated media_type=VIDEO for standalone posts (Nov 2023); - // feed videos must be published as REELS shared to the feed. - const body: Record = { - media_type: 'REELS', - video_url: params.videoUrl, - share_to_feed: true, - } - if (params.caption) body.caption = params.caption - if (params.coverUrl) body.cover_url = params.coverUrl - - const containerId = await createMediaContainer(params.accessToken, igUserId, body) - const { statusCode } = await waitForContainerReady(params.accessToken, containerId) - const mediaId = await publishMediaContainer(params.accessToken, igUserId, containerId) - - return { - success: true, - output: { containerId, mediaId, statusCode }, - } - } catch (error) { - return { - success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: error instanceof Error ? error.message : 'Failed to publish video', - } - } + 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: async (response) => { - if (!response.ok) { + const data = await response.json() + if (!response.ok || data.success === false) { return { success: false, - output: { containerId: null, mediaId: null, statusCode: null }, - error: `Failed to resolve Instagram account: ${response.statusText}`, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, + error: data.error || 'Failed to publish video', } } return { success: true, - output: { containerId: null, mediaId: null, statusCode: null }, + output: data.output || { containerId: null, mediaId: null, statusCode: null }, } }, diff --git a/apps/sim/tools/instagram/send_text_message.ts b/apps/sim/tools/instagram/send_text_message.ts index d91f2fe67c3..d49cd296ed9 100644 --- a/apps/sim/tools/instagram/send_text_message.ts +++ b/apps/sim/tools/instagram/send_text_message.ts @@ -48,7 +48,7 @@ export const instagramSendTextMessageTool: ToolConfig< }, request: { - url: () => 'https://graph.instagram.com/v22.0/me?fields=user_id', + url: () => graphUrl('/me', { fields: 'user_id' }), method: 'GET', headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), }, diff --git a/apps/sim/tools/instagram/types.ts b/apps/sim/tools/instagram/types.ts index 70f9ce4842e..c6fe2de8af0 100644 --- a/apps/sim/tools/instagram/types.ts +++ b/apps/sim/tools/instagram/types.ts @@ -78,35 +78,37 @@ export interface InstagramListStoriesResponse extends ToolResponse { } } +/** Uploaded UserFile object, or a public HTTPS URL string (advanced paste / legacy). */ +export type InstagramMediaInput = object | string + export interface InstagramPublishImageParams extends InstagramAccessParams { - imageUrl: string + image: InstagramMediaInput caption?: string altText?: string isAiGenerated?: boolean } export interface InstagramPublishVideoParams extends InstagramAccessParams { - videoUrl: string + video: InstagramMediaInput caption?: string - coverUrl?: string + cover?: InstagramMediaInput } export interface InstagramPublishReelParams extends InstagramAccessParams { - videoUrl: string + video: InstagramMediaInput caption?: string - coverUrl?: string + cover?: InstagramMediaInput shareToFeed?: boolean thumbOffset?: number } export interface InstagramPublishStoryParams extends InstagramAccessParams { - imageUrl?: string - videoUrl?: string + media: InstagramMediaInput } export interface InstagramPublishCarouselParams extends InstagramAccessParams { - /** Comma-separated public image/video URLs (max 10). Prefix video URLs with video: */ - mediaUrls: string + /** File array, single file, or comma-separated public URLs (prefix videos with video:). */ + media: InstagramMediaInput | InstagramMediaInput[] caption?: string } @@ -219,6 +221,7 @@ export interface InstagramPrivateReplyParams extends InstagramAccessParams { export interface InstagramPrivateReplyResponse extends ToolResponse { output: { messageId: string | null + recipientId: string | null } } @@ -248,9 +251,6 @@ export interface InstagramGetConversationMessagesResponse extends ToolResponse { messages: Array<{ id: string createdTime: string | null - fromId: string | null - fromUsername: string | null - message: string | null }> } } @@ -290,6 +290,7 @@ export interface InstagramGetAccountInsightsParams extends InstagramAccessParams until?: string metricType?: string breakdown?: string + timeframe?: string } export interface InstagramGetAccountInsightsResponse extends ToolResponse { @@ -316,7 +317,10 @@ export interface InstagramGetMediaInsightsResponse extends ToolResponse { insights: Array<{ name: string | null period: string | null + title: string | null + description: string | null values: unknown[] + totalValue: unknown }> } } diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index 5896c21b2f9..96080c50e61 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -1,7 +1,5 @@ import { sleep } from '@sim/utils/helpers' - -export const INSTAGRAM_GRAPH_VERSION = 'v22.0' -export const INSTAGRAM_GRAPH_BASE = `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}` +import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram' export function bearerHeaders(accessToken: string): Record { return { @@ -81,8 +79,9 @@ export async function getContainerStatus( } } -const POLL_INTERVAL_MS = 8_000 -const POLL_MAX_ATTEMPTS = 40 // ~5 minutes +// Meta recommends checking once per minute for no more than five minutes. +const POLL_INTERVAL_MS = 60_000 +const POLL_MAX_ATTEMPTS = 5 export async function waitForContainerReady( accessToken: string, @@ -100,7 +99,9 @@ export async function waitForContainerReady( ) } - await sleep(POLL_INTERVAL_MS) + if (attempt < POLL_MAX_ATTEMPTS - 1) { + await sleep(POLL_INTERVAL_MS) + } } throw new Error(`Timed out waiting for Instagram container ${containerId} to finish processing`) diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 6c2dc6d891d..38d7e28ae09 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -4842,22 +4842,6 @@ export const tools: Record = { hex_update_collection: hexUpdateCollectionTool, hex_update_group: hexUpdateGroupTool, hex_update_project: hexUpdateProjectTool, - instantly_activate_campaign: instantlyActivateCampaignTool, - instantly_create_campaign: instantlyCreateCampaignTool, - instantly_create_lead: instantlyCreateLeadTool, - instantly_create_lead_list: instantlyCreateLeadListTool, - instantly_delete_campaign: instantlyDeleteCampaignTool, - instantly_delete_leads: instantlyDeleteLeadsTool, - instantly_get_lead: instantlyGetLeadTool, - instantly_list_campaigns: instantlyListCampaignsTool, - instantly_list_emails: instantlyListEmailsTool, - instantly_list_lead_lists: instantlyListLeadListsTool, - instantly_list_leads: instantlyListLeadsTool, - instantly_patch_campaign: instantlyPatchCampaignTool, - instantly_patch_lead: instantlyPatchLeadTool, - instantly_pause_campaign: instantlyPauseCampaignTool, - instantly_reply_to_email: instantlyReplyToEmailTool, - instantly_update_lead_interest_status: instantlyUpdateLeadInterestStatusTool, instagram_delete_comment: instagramDeleteCommentTool, instagram_get_account_insights: instagramGetAccountInsightsTool, instagram_get_container_status: instagramGetContainerStatusTool, @@ -4881,6 +4865,22 @@ export const tools: Record = { 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, + instantly_create_lead_list: instantlyCreateLeadListTool, + instantly_delete_campaign: instantlyDeleteCampaignTool, + instantly_delete_leads: instantlyDeleteLeadsTool, + instantly_get_lead: instantlyGetLeadTool, + instantly_list_campaigns: instantlyListCampaignsTool, + instantly_list_emails: instantlyListEmailsTool, + instantly_list_lead_lists: instantlyListLeadListsTool, + instantly_list_leads: instantlyListLeadsTool, + instantly_patch_campaign: instantlyPatchCampaignTool, + instantly_patch_lead: instantlyPatchLeadTool, + instantly_pause_campaign: instantlyPauseCampaignTool, + instantly_reply_to_email: instantlyReplyToEmailTool, + instantly_update_lead_interest_status: instantlyUpdateLeadInterestStatusTool, jina_read_url: jinaReadUrlTool, jina_search: jinaSearchTool, ketch_get_consent: ketchGetConsentTool, From 5897592dee60be2dc1745aedf37a84e7c1b60418 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 10 Jul 2026 13:14:03 -0700 Subject: [PATCH 3/8] refactor(instagram): simplify messaging tools to direct requests, clean up types --- .../integrations/instagram/resolve-media.ts | 338 ++++++------------ .../lib/uploads/utils/file-utils.server.ts | 112 +++++- .../tools/instagram/get_account_insights.ts | 2 +- .../sim/tools/instagram/get_media_insights.ts | 2 +- apps/sim/tools/instagram/private_reply.ts | 72 ++-- apps/sim/tools/instagram/send_text_message.ts | 78 ++-- apps/sim/tools/instagram/types.ts | 4 +- 7 files changed, 255 insertions(+), 353 deletions(-) diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/lib/integrations/instagram/resolve-media.ts index 3fcb5478cab..5eedab6edcc 100644 --- a/apps/sim/lib/integrations/instagram/resolve-media.ts +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -1,20 +1,13 @@ import type { Logger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server' -import { StorageService } from '@/lib/uploads' import { hasCloudStorage } from '@/lib/uploads/core/storage-service' import { - extractStorageKey, getFileExtension, getMimeTypeFromExtension, isInternalFileUrl, - processSingleFileToUserFile, type RawFileInput, resolveFileType, - resolveTrustedFileContext, } from '@/lib/uploads/utils/file-utils' -import { verifyFileAccess } from '@/app/api/files/authorization' -import type { UserFile } from '@/executor/types' +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 @@ -28,6 +21,9 @@ 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 or Blob storage, or paste a public HTTPS URL instead.' + export type InstagramMediaRole = 'image' | 'video' | 'cover' | 'story' | 'carousel' export interface ResolvedInstagramMedia { @@ -87,8 +83,6 @@ function validateMediaConstraints( label: string ): string | null { const mime = (mimeType || '').toLowerCase() - const isJpeg = !mime || JPEG_MIME.has(mime) - const isVideoMime = !mime || VIDEO_MIME.has(mime) if (kind === 'image') { if (mime && !JPEG_MIME.has(mime)) { @@ -97,19 +91,12 @@ function validateMediaConstraints( if (size != null && size > IMAGE_MAX_BYTES) { return `${label} exceeds Instagram's ${formatBytes(IMAGE_MAX_BYTES)} JPEG limit (got ${formatBytes(size)})` } - if (!isJpeg && mime) { - return `${label} must be a JPEG image` - } return null } - // video if (mime && !VIDEO_MIME.has(mime)) { return `${label} must be an MP4 or MOV video (got ${mime})` } - if (!isVideoMime && mime) { - return `${label} must be an MP4 or MOV video` - } const maxBytes = role === 'story' ? STORY_VIDEO_MAX_BYTES : REEL_VIDEO_MAX_BYTES if (size != null && size > maxBytes) { @@ -118,209 +105,57 @@ function validateMediaConstraints( return null } -function isPublicHttpsUrl(value: string): boolean { +function isPublicHttpUrl(value: string): boolean { return (value.startsWith('https://') || value.startsWith('http://')) && !isInternalFileUrl(value) } -async function resolvePublicUrl( - url: string, - requestId: string, - logger: Logger -): Promise { - if (!url.startsWith('https://')) { - return { - error: { - status: 400, - message: 'Instagram media URLs must use HTTPS so Meta can download them', - }, - } - } - - const validation = await validateUrlWithDNS(url, 'instagramMediaUrl') - if (!validation.isValid) { - logger.warn(`[${requestId}] Invalid Instagram media URL`, { error: validation.error }) - return { error: { status: 400, message: validation.error || 'Invalid media URL' } } - } - - return { media: { url, kind: 'image' } } -} - -async function presignUserFile( - userFile: UserFile, - userId: string, - requestId: string, - logger: Logger -): Promise { - if (!hasCloudStorage()) { - return { - error: { - status: 400, - message: - 'Cloud storage is required to publish uploaded Instagram media. Configure S3 or Blob storage, or paste a public HTTPS URL instead.', - }, - } - } - - let key = userFile.key - if (!key && userFile.url && isInternalFileUrl(userFile.url)) { - key = extractStorageKey(userFile.url) - } - - if (!key) { - if (userFile.url && isPublicHttpsUrl(userFile.url)) { - return resolvePublicUrl(userFile.url, requestId, logger) - } - return { - error: { - status: 400, - message: 'Uploaded file is missing a storage key and cannot be shared with Instagram', - }, - } - } - - const context = resolveTrustedFileContext(key, userFile.context) - const hasAccess = await verifyFileAccess(key, userId, undefined, context, false) - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized Instagram media presign attempt`, { - userId, - key, - context, - }) - return { error: { status: 404, message: 'File not found' } } - } - - try { - const url = await StorageService.generatePresignedDownloadUrl( - key, - context, - INSTAGRAM_MEDIA_URL_TTL_SECONDS - ) - logger.info(`[${requestId}] Generated Instagram media presigned URL`, { - key, - ttlSeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS, - }) - return { - media: { - url, - kind: 'image', - mimeType: userFile.type, - size: userFile.size, - name: userFile.name, - }, - } - } catch (error) { - logger.error(`[${requestId}] Failed to generate Instagram media URL:`, error) - return { - error: { - status: 500, - message: getErrorMessage(error, 'Failed to generate a Meta-fetchable media URL'), - }, - } - } +function needsCloudStorage(file?: RawFileInput, filePath?: string): boolean { + if (file) return true + if (filePath && isInternalFileUrl(filePath)) return true + return false } /** - * Resolve a UserFile, internal serve path, or public HTTPS URL into a Meta-fetchable HTTPS URL. - * Uploaded files are re-presigned with a 600s TTL so Meta can curl them during container processing. + * Split a canonical media input into the shapes {@link resolveFileInputToUrl} expects + * (Reducto/STT pattern: file object vs filePath string). */ -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 {} - } - - let resolved: ResolveInstagramMediaResult - +function splitMediaInput(input: unknown): { + file?: RawFileInput + filePath?: string + name?: string + size?: number + mimeType?: string + error?: { status: number; message: string } +} { if (typeof input === 'string') { - const trimmed = input.trim() - if (!trimmed) { - if (required) { - return { error: { status: 400, message: `${label} is required` } } - } - return {} - } + return { filePath: input.trim() } + } - if (isInternalFileUrl(trimmed)) { - if (!hasCloudStorage()) { - return { - error: { - status: 400, - message: - 'Cloud storage is required to publish uploaded Instagram media. Configure S3 or Blob storage, or paste a public HTTPS URL instead.', - }, - } - } - const key = extractStorageKey(trimmed) - const context = resolveTrustedFileContext(key) - const hasAccess = await verifyFileAccess(key, userId, undefined, context, false) - if (!hasAccess) { - return { error: { status: 404, message: 'File not found' } } - } - try { - const url = await StorageService.generatePresignedDownloadUrl( - key, - context, - INSTAGRAM_MEDIA_URL_TTL_SECONDS - ) - resolved = { media: { url, kind: 'image' } } - } catch (error) { - logger.error(`[${requestId}] Failed to presign internal Instagram media path:`, error) - return { - error: { - status: 500, - message: getErrorMessage(error, 'Failed to generate a Meta-fetchable media URL'), - }, - } - } - } else if (isPublicHttpsUrl(trimmed) || trimmed.startsWith('http://')) { - resolved = await resolvePublicUrl(trimmed, requestId, logger) - } else { - return { - error: { - status: 400, - message: `${label} must be an uploaded file or a public HTTPS URL`, - }, - } - } - } else if (typeof input === 'object') { - let userFile: UserFile - try { - userFile = processSingleFileToUserFile(input as RawFileInput, requestId, logger) - } catch (error) { - return { - error: { - status: 400, - message: getErrorMessage(error, `Failed to process ${label.toLowerCase()}`), - }, - } + 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' } } } - resolved = await presignUserFile(userFile, userId, requestId, logger) - if (resolved.media) { - resolved.media.mimeType = resolveFileType({ - type: userFile.type || '', - name: userFile.name, - }) - resolved.media.size = userFile.size - resolved.media.name = userFile.name + return { + file: raw, + name: raw.name, + size: typeof raw.size === 'number' ? raw.size : undefined, + mimeType: resolveFileType({ type: raw.type || '', name: raw.name }), } - } else { - return { error: { status: 400, message: `${label} must be a file or URL string` } } } - if (resolved.error || !resolved.media) { - return resolved - } + 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 = - resolved.media.mimeType || - getMimeTypeFromExtension(extensionFromUrlOrName(resolved.media.name || resolved.media.url)) - const extension = extensionFromUrlOrName(resolved.media.name || resolved.media.url) + meta.mimeType || getMimeTypeFromExtension(extensionFromUrlOrName(meta.name || url)) + const extension = extensionFromUrlOrName(meta.name || url) let kind: 'image' | 'video' if (role === 'image' || role === 'cover') { @@ -340,7 +175,6 @@ export async function resolveInstagramMedia( kind = inferred } - // Prefer extension hints when MIME is generic if ( (role === 'story' || role === 'carousel') && (!mimeType || mimeType === 'application/octet-stream') @@ -353,40 +187,102 @@ export async function resolveInstagramMedia( kind, role, mimeType === 'application/octet-stream' ? undefined : mimeType, - resolved.media.size, + meta.size, label ) if (constraintError) { return { error: { status: 400, message: constraintError } } } - // Soft extension check for public URLs without MIME if (kind === 'image' && extension && !JPEG_EXT.has(extension) && !mimeType) { return { - error: { - status: 400, - message: `${label} must be a JPEG (.jpg/.jpeg)`, - }, + 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`, - }, + error: { status: 400, message: `${label} must be an MP4 or MOV video` }, } } return { media: { - ...resolved.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). @@ -409,7 +305,6 @@ export async function resolveInstagramCarouselMedia( return { error: { status: 400, message: 'Carousel media is required' } } } - // JSON-serialized file array from advanced mode if (trimmed.startsWith('[') || trimmed.startsWith('{')) { try { const parsed = JSON.parse(trimmed) as unknown @@ -435,7 +330,6 @@ export async function resolveInstagramCarouselMedia( const entry = entries[i] const isVideoPrefixed = entry.toLowerCase().startsWith('video:') const raw = isVideoPrefixed ? entry.slice('video:'.length).trim() : entry - // Legacy comma-separated URLs: plain = image, `video:` prefix = video const result = await resolveInstagramMedia({ input: raw, userId, @@ -452,11 +346,7 @@ export async function resolveInstagramCarouselMedia( }, } } - if (isVideoPrefixed) { - result.media.kind = 'video' - } else { - result.media.kind = 'image' - } + result.media.kind = isVideoPrefixed ? 'video' : 'image' items.push(result.media) } 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/get_account_insights.ts b/apps/sim/tools/instagram/get_account_insights.ts index 0b52d4fa429..3af2b77cab4 100644 --- a/apps/sim/tools/instagram/get_account_insights.ts +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -86,7 +86,7 @@ export const instagramGetAccountInsightsTool: ToolConfig< url: (params) => { const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/insights` : '/me/insights' return graphUrl(path, { - metric: parseCommaSeparated(params.metrics).join(',') || params.metrics.trim(), + metric: parseCommaSeparated(params.metrics).join(','), period: params.period.trim(), since: params.since?.trim() || undefined, until: params.until?.trim() || undefined, diff --git a/apps/sim/tools/instagram/get_media_insights.ts b/apps/sim/tools/instagram/get_media_insights.ts index ef398c4159a..b4f417dd81f 100644 --- a/apps/sim/tools/instagram/get_media_insights.ts +++ b/apps/sim/tools/instagram/get_media_insights.ts @@ -49,7 +49,7 @@ export const instagramGetMediaInsightsTool: ToolConfig< request: { url: (params) => graphUrl(`/${params.mediaId.trim()}/insights`, { - metric: parseCommaSeparated(params.metrics).join(',') || params.metrics.trim(), + metric: parseCommaSeparated(params.metrics).join(','), }), method: 'GET', headers: (params) => bearerHeaders(params.accessToken), diff --git a/apps/sim/tools/instagram/private_reply.ts b/apps/sim/tools/instagram/private_reply.ts index 7f29676d5f2..b90d798f04e 100644 --- a/apps/sim/tools/instagram/private_reply.ts +++ b/apps/sim/tools/instagram/private_reply.ts @@ -2,7 +2,7 @@ import type { InstagramPrivateReplyParams, InstagramPrivateReplyResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError, resolveIgUserId } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPrivateReplyTool: ToolConfig< @@ -47,65 +47,35 @@ export const instagramPrivateReplyTool: ToolConfig< }, request: { - url: () => graphUrl('/me', { fields: 'user_id' }), - method: 'GET', - headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { comment_id: params.commentId.trim() }, + message: { text: params.message }, + }), }, - postProcess: async (result, params) => { - if (!result.success) { - return { - success: false, - output: { messageId: null, recipientId: null }, - error: result.error || 'Failed to resolve Instagram account', - } - } - - try { - const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) - const response = await fetch(graphUrl(`/${igUserId}/messages`), { - method: 'POST', - headers: bearerHeaders(params.accessToken), - body: JSON.stringify({ - recipient: { comment_id: params.commentId.trim() }, - message: { text: params.message }, - }), - }) - - if (!response.ok) { - return { - success: false, - output: { messageId: null, recipientId: null }, - error: await readGraphError(response), - } - } - - const data = (await response.json()) as { message_id?: string; recipient_id?: string } - return { - success: true, - output: { - messageId: data.message_id ?? null, - recipientId: data.recipient_id ?? null, - }, - } - } catch (error) { + transformResponse: async (response): Promise => { + if (!response.ok) { return { success: false, output: { messageId: null, recipientId: null }, - error: error instanceof Error ? error.message : 'Failed to send private reply', + error: await readGraphError(response), } } - }, - transformResponse: async (response) => { - if (!response.ok) { - return { - success: false, - output: { messageId: null, recipientId: null }, - error: `Failed to resolve Instagram account: ${response.statusText}`, - } + const data = (await response.json()) as { message_id?: string; recipient_id?: string } + return { + success: true, + output: { + messageId: data.message_id ?? null, + recipientId: data.recipient_id ?? null, + }, } - return { success: true, output: { messageId: null, recipientId: null } } }, outputs: { diff --git a/apps/sim/tools/instagram/send_text_message.ts b/apps/sim/tools/instagram/send_text_message.ts index d49cd296ed9..2ba9f8aa406 100644 --- a/apps/sim/tools/instagram/send_text_message.ts +++ b/apps/sim/tools/instagram/send_text_message.ts @@ -2,7 +2,7 @@ import type { InstagramSendTextMessageParams, InstagramSendTextMessageResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError, resolveIgUserId } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramSendTextMessageTool: ToolConfig< @@ -48,69 +48,35 @@ export const instagramSendTextMessageTool: ToolConfig< }, request: { - url: () => graphUrl('/me', { fields: 'user_id' }), - method: 'GET', - headers: (params) => ({ Authorization: `Bearer ${params.accessToken}` }), + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { id: params.recipientId.trim() }, + message: { text: params.message }, + }), }, - postProcess: async (result, params) => { - if (!result.success) { - return { - success: false, - output: { messageId: null, recipientId: null }, - error: result.error || 'Failed to resolve Instagram account', - } - } - - try { - const igUserId = await resolveIgUserId(params.accessToken, params.igUserId) - const response = await fetch(graphUrl(`/${igUserId}/messages`), { - method: 'POST', - headers: bearerHeaders(params.accessToken), - body: JSON.stringify({ - recipient: { id: params.recipientId.trim() }, - message: { text: params.message }, - }), - }) - - if (!response.ok) { - return { - success: false, - output: { messageId: null, recipientId: params.recipientId.trim() }, - error: await readGraphError(response), - } - } - - const data = (await response.json()) as { - message_id?: string - recipient_id?: string - } - - return { - success: true, - output: { - messageId: data.message_id ?? null, - recipientId: data.recipient_id ?? params.recipientId.trim() ?? null, - }, - } - } catch (error) { + transformResponse: async (response, params): Promise => { + if (!response.ok) { return { success: false, - output: { messageId: null, recipientId: null }, - error: error instanceof Error ? error.message : 'Failed to send message', + output: { messageId: null, recipientId: params?.recipientId.trim() ?? null }, + error: await readGraphError(response), } } - }, - transformResponse: async (response) => { - if (!response.ok) { - return { - success: false, - output: { messageId: null, recipientId: null }, - error: `Failed to resolve Instagram account: ${response.statusText}`, - } + const data = (await response.json()) as { message_id?: string; recipient_id?: string } + return { + success: true, + output: { + messageId: data.message_id ?? null, + recipientId: data.recipient_id ?? params?.recipientId.trim() ?? null, + }, } - return { success: true, output: { messageId: null, recipientId: null } } }, outputs: { diff --git a/apps/sim/tools/instagram/types.ts b/apps/sim/tools/instagram/types.ts index c6fe2de8af0..c2a3c8fcc6b 100644 --- a/apps/sim/tools/instagram/types.ts +++ b/apps/sim/tools/instagram/types.ts @@ -5,7 +5,9 @@ export interface InstagramAccessParams { igUserId?: string } -export interface InstagramGetProfileParams extends InstagramAccessParams {} +export interface InstagramGetProfileParams { + accessToken: string +} export interface InstagramGetProfileResponse extends ToolResponse { output: { From 363a68ed6b0a65293fdc0c2be7e6d4864001ca7c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 10 Jul 2026 13:30:17 -0700 Subject: [PATCH 4/8] fix(instagram): parallelize carousel child polling, enforce 2-10 items, extend poll window and insights periods, use canonical user_id in OAuth callback --- .../docs/en/integrations/instagram.mdx | 4 +-- .../auth/oauth2/callback/instagram/route.ts | 15 +++++------ .../tools/instagram/publish-carousel/route.ts | 25 ++++++++++++++----- apps/sim/blocks/blocks/instagram.ts | 9 ++++--- .../integrations/instagram/resolve-media.ts | 14 +++-------- apps/sim/lib/integrations/integrations.json | 2 +- apps/sim/tools/instagram/publish_carousel.ts | 5 ++-- apps/sim/tools/instagram/utils.ts | 4 ++- 8 files changed, 43 insertions(+), 35 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/instagram.mdx b/apps/docs/content/docs/en/integrations/instagram.mdx index 5c7eb79d4a4..01697d4f281 100644 --- a/apps/docs/content/docs/en/integrations/instagram.mdx +++ b/apps/docs/content/docs/en/integrations/instagram.mdx @@ -190,14 +190,14 @@ Publish an image or video story from an uploaded file or public HTTPS URL ### `instagram_publish_carousel` -Publish a carousel of up to 10 images/videos from uploaded files or public HTTPS URLs +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 | Up to 10 media files, or a comma-separated public URL string \(prefix videos with video:\) | +| `media` | file[] | Yes | 2-10 media files, or a comma-separated public URL string \(prefix videos with video:\) | | `caption` | string | No | Carousel caption | #### Output diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 17a40f7ca45..24957fafdaa 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -206,17 +206,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { name?: string } - const igUserId = - (typeof profile.user_id === 'string' && profile.user_id) || - (typeof shortLived.user_id === 'string' - ? shortLived.user_id - : shortLived.user_id != null - ? String(shortLived.user_id) - : null) || - (typeof profile.id === 'string' ? profile.id : null) + // account.accountId must always be the Instagram professional account ID + // (/me user_id). 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 = typeof profile.user_id === 'string' && profile.user_id ? profile.user_id : null if (!igUserId) { - logger.error('Instagram profile response missing user id', { profile }) + logger.error('Instagram profile response missing user_id', { profile }) return clearOAuthCookies( NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`) ) diff --git a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts index 241b17a3e39..95fd745cbdc 100644 --- a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -16,8 +16,8 @@ import { export const dynamic = 'force-dynamic' /** - * Carousel children are created and polled sequentially; allow headroom beyond a - * single container's five-minute window. + * 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 @@ -58,8 +58,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) - const childIds: string[] = [] + // 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, @@ -70,10 +74,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else { childBody.image_url = item.url } + childIds.push(await createMediaContainer(body.accessToken, igUserId, childBody)) + } - const childId = await createMediaContainer(body.accessToken, igUserId, childBody) - await waitForContainerReady(body.accessToken, childId) - childIds.push(childId) + // 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 = { diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index 6d99b25adda..03d97614014 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -238,7 +238,7 @@ export const InstagramBlock: BlockConfig = { title: 'Media', type: 'file-upload', canonicalParamId: 'carouselMedia', - placeholder: 'Upload up to 10 images/videos to publish', + placeholder: 'Upload 2-10 images/videos to publish', acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', condition: { field: 'operation', value: 'instagram_publish_carousel' }, mode: 'basic', @@ -259,7 +259,7 @@ export const InstagramBlock: BlockConfig = { 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). -Maximum 10 items. Do not invent unreachable URLs — only use URLs the user provided or clearly implied. +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 @@ -481,7 +481,10 @@ Return ONLY the comma-separated metric names - no explanations, no extra text.`, 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' }, @@ -705,7 +708,7 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, carouselMedia: { type: 'json', description: - 'Carousel media: up to 10 files, or comma-separated public HTTPS URLs (prefix videos with video:)', + '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' }, diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/lib/integrations/instagram/resolve-media.ts index 5eedab6edcc..139068d7fce 100644 --- a/apps/sim/lib/integrations/instagram/resolve-media.ts +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -319,11 +319,8 @@ export async function resolveInstagramCarouselMedia( .map((part) => part.trim()) .filter(Boolean) - if (entries.length === 0) { - return { error: { status: 400, message: 'Provide at least one carousel media item' } } - } - if (entries.length > 10) { - return { error: { status: 400, message: 'Carousels support a maximum of 10 items' } } + 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++) { @@ -354,11 +351,8 @@ export async function resolveInstagramCarouselMedia( } const list = Array.isArray(input) ? input : [input] - if (list.length === 0) { - return { error: { status: 400, message: 'Provide at least one carousel media item' } } - } - if (list.length > 10) { - return { error: { status: 400, message: 'Carousels support a maximum of 10 items' } } + 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++) { diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index a2af2efcd43..880a4802bc4 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -8974,7 +8974,7 @@ }, { "name": "Publish Carousel", - "description": "Publish a carousel of up to 10 images/videos from uploaded files or public HTTPS URLs" + "description": "Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs" }, { "name": "Get Container Status", diff --git a/apps/sim/tools/instagram/publish_carousel.ts b/apps/sim/tools/instagram/publish_carousel.ts index ac0db640328..1987d71a0d9 100644 --- a/apps/sim/tools/instagram/publish_carousel.ts +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -10,8 +10,7 @@ export const instagramPublishCarouselTool: ToolConfig< > = { id: 'instagram_publish_carousel', name: 'Instagram Publish Carousel', - description: - 'Publish a carousel of up to 10 images/videos from uploaded files or public HTTPS URLs', + description: 'Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs', version: '1.0.0', oauth: { @@ -37,7 +36,7 @@ export const instagramPublishCarouselTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Up to 10 media files, or a comma-separated public URL string (prefix videos with video:)', + '2-10 media files, or a comma-separated public URL string (prefix videos with video:)', }, caption: { type: 'string', diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index 96080c50e61..1f636eca686 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -80,8 +80,10 @@ export async function getContainerStatus( } // 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 = 5 +const POLL_MAX_ATTEMPTS = 6 export async function waitForContainerReady( accessToken: string, From e310f9752451ef1faabba9d4e4f18d42b2fa17bf Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 10 Jul 2026 13:41:15 -0700 Subject: [PATCH 5/8] fix(instagram): resolve user id from user_id only, accept numeric user_id Co-authored-by: Cursor --- .../api/auth/oauth2/callback/instagram/route.ts | 12 +++++++----- apps/sim/tools/instagram/utils.ts | 14 ++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 24957fafdaa..42e56365b5c 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -200,17 +200,19 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } const profile = (await profileResponse.json()) as { - user_id?: string + user_id?: string | number id?: string username?: string name?: string } // account.accountId must always be the Instagram professional account ID - // (/me user_id). 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 = typeof profile.user_id === 'string' && profile.user_id ? profile.user_id : null + // (/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 }) diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index 1f636eca686..c3751e72558 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -42,7 +42,7 @@ export async function resolveIgUserId(accessToken: string, igUserId?: string): P return igUserId.trim() } - const response = await fetch(graphUrl('/me', { fields: 'user_id,id' }), { + const response = await fetch(graphUrl('/me', { fields: 'user_id' }), { headers: bearerHeaders(accessToken), }) @@ -50,12 +50,14 @@ export async function resolveIgUserId(accessToken: string, igUserId?: string): P throw new Error(`Failed to resolve Instagram user id: ${await readGraphError(response)}`) } - const data = (await response.json()) as { user_id?: string; id?: string } - const resolved = data.user_id || data.id - if (!resolved) { - throw new Error('Instagram /me response did not include a user id') + // 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 resolved + return String(data.user_id) } export type ContainerStatusCode = 'EXPIRED' | 'ERROR' | 'FINISHED' | 'IN_PROGRESS' | 'PUBLISHED' From 05f51a35ece1d2b3367b73154db10c880ca112e8 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 10 Jul 2026 13:49:19 -0700 Subject: [PATCH 6/8] fix(instagram): use form/query params for publish and comment endpoints, request message timestamps explicitly Co-authored-by: Cursor --- .../instagram/get_conversation_messages.ts | 2 +- apps/sim/tools/instagram/reply_to_comment.ts | 5 ++- apps/sim/tools/instagram/utils.ts | 39 ++++++++++++++----- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/sim/tools/instagram/get_conversation_messages.ts b/apps/sim/tools/instagram/get_conversation_messages.ts index a1de2e7e7bc..5f61253544c 100644 --- a/apps/sim/tools/instagram/get_conversation_messages.ts +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -38,7 +38,7 @@ export const instagramGetConversationMessagesTool: ToolConfig< request: { url: (params) => graphUrl(`/${params.conversationId.trim()}`, { - fields: 'messages', + fields: 'messages{id,created_time}', }), method: 'GET', headers: (params) => bearerHeaders(params.accessToken), diff --git a/apps/sim/tools/instagram/reply_to_comment.ts b/apps/sim/tools/instagram/reply_to_comment.ts index f5192873af3..5f40f0c18e4 100644 --- a/apps/sim/tools/instagram/reply_to_comment.ts +++ b/apps/sim/tools/instagram/reply_to_comment.ts @@ -41,10 +41,11 @@ export const instagramReplyToCommentTool: ToolConfig< }, request: { - url: (params) => graphUrl(`/${params.commentId.trim()}/replies`), + // 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), - body: (params) => ({ message: params.message }), }, transformResponse: async (response): Promise => { diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index c3751e72558..d9a8824a14d 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -111,16 +111,39 @@ export async function waitForContainerReady( 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 fetch(graphUrl(`/${igUserId}/media`), { - method: 'POST', - headers: bearerHeaders(accessToken), - body: JSON.stringify(body), - }) + const response = await postGraphForm(accessToken, `/${igUserId}/media`, body) if (!response.ok) { throw new Error(`Failed to create media container: ${await readGraphError(response)}`) @@ -138,10 +161,8 @@ export async function publishMediaContainer( igUserId: string, creationId: string ): Promise { - const response = await fetch(graphUrl(`/${igUserId}/media_publish`), { - method: 'POST', - headers: bearerHeaders(accessToken), - body: JSON.stringify({ creation_id: creationId }), + const response = await postGraphForm(accessToken, `/${igUserId}/media_publish`, { + creation_id: creationId, }) if (!response.ok) { From 293230a416f825c5f47c635a8d08a085549d6c6f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 10 Jul 2026 13:57:57 -0700 Subject: [PATCH 7/8] fix(instagram): normalize Graph ID outputs to strings so downstream .trim() calls are safe Co-authored-by: Cursor --- .../tools/instagram/get_container_status.ts | 4 ++-- .../instagram/get_conversation_messages.ts | 4 ++-- apps/sim/tools/instagram/get_media.ts | 4 ++-- apps/sim/tools/instagram/get_message.ts | 10 ++++---- apps/sim/tools/instagram/get_profile.ts | 6 ++--- apps/sim/tools/instagram/private_reply.ts | 11 +++++---- apps/sim/tools/instagram/reply_to_comment.ts | 4 ++-- apps/sim/tools/instagram/send_text_message.ts | 11 +++++---- apps/sim/tools/instagram/utils.ts | 23 ++++++++++++++----- 9 files changed, 47 insertions(+), 30 deletions(-) diff --git a/apps/sim/tools/instagram/get_container_status.ts b/apps/sim/tools/instagram/get_container_status.ts index fb93f4ac1d4..43f62f8ee36 100644 --- a/apps/sim/tools/instagram/get_container_status.ts +++ b/apps/sim/tools/instagram/get_container_status.ts @@ -2,7 +2,7 @@ import type { InstagramGetContainerStatusParams, InstagramGetContainerStatusResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramGetContainerStatusTool: ToolConfig< @@ -57,7 +57,7 @@ export const instagramGetContainerStatusTool: ToolConfig< return { success: true, output: { - containerId: params?.containerId ?? data.id ?? '', + containerId: params?.containerId ?? idString(data.id) ?? '', statusCode: data.status_code ?? null, status: data.status ?? null, }, diff --git a/apps/sim/tools/instagram/get_conversation_messages.ts b/apps/sim/tools/instagram/get_conversation_messages.ts index 5f61253544c..817873fd71c 100644 --- a/apps/sim/tools/instagram/get_conversation_messages.ts +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -2,7 +2,7 @@ import type { InstagramGetConversationMessagesParams, InstagramGetConversationMessagesResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramGetConversationMessagesTool: ToolConfig< @@ -62,7 +62,7 @@ export const instagramGetConversationMessagesTool: ToolConfig< return { success: true, output: { - conversationId: params?.conversationId ?? data.id ?? '', + conversationId: params?.conversationId ?? idString(data.id) ?? '', messages: items.map((item: Record) => ({ id: String(item.id ?? ''), createdTime: (item.created_time as string | undefined) ?? null, diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts index 230e2640c03..954a883b7f5 100644 --- a/apps/sim/tools/instagram/get_media.ts +++ b/apps/sim/tools/instagram/get_media.ts @@ -1,5 +1,5 @@ import type { InstagramGetMediaParams, InstagramGetMediaResponse } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' const DEFAULT_FIELDS = @@ -73,7 +73,7 @@ export const instagramGetMediaTool: ToolConfig { const text = await response.text() try { @@ -149,11 +158,12 @@ export async function createMediaContainer( throw new Error(`Failed to create media container: ${await readGraphError(response)}`) } - const data = (await response.json()) as { id?: string } - if (!data.id) { + 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 data.id + return id } export async function publishMediaContainer( @@ -169,11 +179,12 @@ export async function publishMediaContainer( throw new Error(`Failed to publish media: ${await readGraphError(response)}`) } - const data = (await response.json()) as { id?: string } - if (!data.id) { + 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 data.id + return id } export function parseCommaSeparated(value?: string): string[] { From c3c4f1dd112d57ee033fd2bec91f90114f7d3c27 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Fri, 10 Jul 2026 15:34:56 -0700 Subject: [PATCH 8/8] style(instagram): use brand gradient tile for the block icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the official Instagram look by filling the tile with the orange–pink–purple radial gradient so the white camera glyph sits on a full-bleed brand background. Co-authored-by: Cursor --- apps/sim/blocks/blocks/instagram.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index 03d97614014..75f97a8dd95 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -63,7 +63,9 @@ export const InstagramBlock: BlockConfig = { docsLink: 'https://docs.sim.ai/integrations/instagram', category: 'tools', integrationType: IntegrationType.Marketing, - bgColor: '#E4405F', + // 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: [