From 41e0e64e21b5c43650d1bae5986ba186a83fdd9d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Thu, 9 Jul 2026 17:25:46 -0700 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 07/16] 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 08/16] 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: [ From 9236d5b45f20c70453f87a1966bc4a7677031f1a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 11 Jul 2026 17:29:25 -0700 Subject: [PATCH 09/16] fix(instagram): tighten publish defaults and cloud-storage upload UX Default Reel share-to-feed to Yes, drop unused media fields params, share publish transform helpers, and warn when cloud storage is missing for Meta-fetchable uploads. Co-authored-by: Cursor --- .../docs/en/integrations/instagram.mdx | 206 +++++++++++++++++- apps/sim/.env.example | 2 + .../sim/app/api/files/storage-status/route.ts | 19 ++ .../components/file-upload/file-upload.tsx | 34 ++- .../editor/components/sub-block/sub-block.tsx | 1 + apps/sim/blocks/blocks/instagram.ts | 26 ++- apps/sim/blocks/types.ts | 5 + apps/sim/hooks/queries/workspace-files.ts | 27 +++ .../integrations/instagram/resolve-media.ts | 2 +- apps/sim/lib/integrations/integrations.json | 4 +- apps/sim/tools/instagram/get_media.ts | 11 +- apps/sim/tools/instagram/list_media.ts | 10 +- apps/sim/tools/instagram/private_reply.ts | 4 +- apps/sim/tools/instagram/publish_carousel.ts | 22 +- apps/sim/tools/instagram/publish_image.ts | 22 +- apps/sim/tools/instagram/publish_reel.ts | 22 +- apps/sim/tools/instagram/publish_story.ts | 22 +- apps/sim/tools/instagram/publish_video.ts | 22 +- apps/sim/tools/instagram/send_text_message.ts | 8 +- apps/sim/tools/instagram/types.ts | 2 - apps/sim/tools/instagram/utils.ts | 31 +++ 21 files changed, 356 insertions(+), 146 deletions(-) create mode 100644 apps/sim/app/api/files/storage-status/route.ts diff --git a/apps/docs/content/docs/en/integrations/instagram.mdx b/apps/docs/content/docs/en/integrations/instagram.mdx index 01697d4f281..29da3afe9f9 100644 --- a/apps/docs/content/docs/en/integrations/instagram.mdx +++ b/apps/docs/content/docs/en/integrations/instagram.mdx @@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card" ## Usage Instructions @@ -52,7 +52,6 @@ List recent media on the Instagram professional account | `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 @@ -70,7 +69,6 @@ Get details for a specific Instagram media object | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `mediaId` | string | Yes | Instagram media id | -| `fields` | string | No | Comma-separated fields to request | #### Output @@ -121,9 +119,47 @@ Create and publish a single JPEG image post from an uploaded file or public HTTP | Parameter | Type | Description | | --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | | `containerId` | string | Media container id | | `mediaId` | string | Published media id | -| `statusCode` | string | Final container status | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | ### `instagram_publish_video` @@ -142,9 +178,47 @@ Create and publish a feed video from an uploaded file or public HTTPS URL (publi | Parameter | Type | Description | | --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | | `containerId` | string | Media container id | | `mediaId` | string | Published media id | -| `statusCode` | string | Final container status | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | ### `instagram_publish_reel` @@ -165,9 +239,47 @@ Create and publish a Reel from an uploaded video file or public HTTPS URL (polls | Parameter | Type | Description | | --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | | `containerId` | string | Media container id | | `mediaId` | string | Published media id | -| `statusCode` | string | Final container status | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | ### `instagram_publish_story` @@ -184,9 +296,47 @@ Publish an image or video story from an uploaded file or public HTTPS URL | Parameter | Type | Description | | --------- | ---- | ----------- | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | | `containerId` | string | Media container id | | `mediaId` | string | Published media id | -| `statusCode` | string | Final container status | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | ### `instagram_publish_carousel` @@ -204,9 +354,47 @@ Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URL | Parameter | Type | Description | | --------- | ---- | ----------- | -| `containerId` | string | Carousel container id | +| `userId` | string | Instagram professional account user_id | +| `id` | string | Graph object or message id | +| `username` | string | Instagram username | +| `name` | string | Display name | +| `accountType` | string | Business or Media_Creator | +| `profilePictureUrl` | string | Profile picture URL | +| `followersCount` | number | Follower count | +| `followsCount` | number | Following count | +| `mediaCount` | number | Media count | +| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | +| `caption` | string | Media caption text | +| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | +| `mediaProductType` | string | Feed, Reels, or Stories product type | +| `mediaUrl` | string | CDN media URL when available | +| `permalink` | string | Permalink to the post | +| `timestamp` | string | ISO timestamp | +| `likeCount` | number | Like count | +| `commentsCount` | number | Comments count | +| `children` | json | Carousel child media ids | +| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | +| `containerId` | string | Media container id | | `mediaId` | string | Published media id | -| `statusCode` | string | Final status | +| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | +| `status` | string | Detailed container status message | +| `quotaUsage` | number | Publishes used in the current window | +| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | +| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | +| `success` | boolean | Whether the operation succeeded | +| `conversations` | json | Conversations \(id, updatedTime\) | +| `conversationId` | string | Conversation id | +| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | +| `createdTime` | string | Message created timestamp | +| `fromId` | string | Sender Instagram-scoped id | +| `fromUsername` | string | Sender username | +| `toId` | string | Recipient id | +| `message` | string | Message text | +| `messageId` | string | Sent or retrieved message id | +| `recipientId` | string | DM recipient id | +| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | +| `nextCursor` | string | Pagination cursor for the next page | +| `error` | string | Error message if the operation failed | ### `instagram_get_container_status` diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f0231320032..a6964ebd45b 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -96,6 +96,8 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # Instagram OAuth (Optional - Instagram App ID/Secret from Meta App Dashboard > Instagram > API setup with Instagram login) # INSTAGRAM_CLIENT_ID= # INSTAGRAM_CLIENT_SECRET= +# Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL). +# Pasting a public HTTPS URL in advanced mode works without cloud storage. Gmail attachments do not need this. # Admin API (Optional - for self-hosted GitOps) # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. diff --git a/apps/sim/app/api/files/storage-status/route.ts b/apps/sim/app/api/files/storage-status/route.ts new file mode 100644 index 00000000000..ee4e82ba2a1 --- /dev/null +++ b/apps/sim/app/api/files/storage-status/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' + +export const dynamic = 'force-dynamic' + +/** + * GET /api/files/storage-status + * Whether S3 or Azure Blob is configured (needed for Instagram file-upload publish). + */ +export const GET = withRouteHandler(async () => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + return NextResponse.json({ cloudConfigured: hasCloudStorage() }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 205db37466c..12c863e91d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -19,6 +19,7 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { + useCloudStorageConfigured, useUploadWorkspaceFile, useWorkspaceFiles, workspaceFilesKeys, @@ -36,6 +37,11 @@ interface FileUploadProps { maxSize?: number // in MB acceptedTypes?: string // comma separated MIME types multiple?: boolean // whether to allow multiple file uploads + /** + * When true, disable new uploads and show a notice if S3/Blob is not configured + * (providers that need a public HTTPS URL Meta can fetch, e.g. Instagram). + */ + requiresCloudStorage?: boolean isPreview?: boolean previewValue?: any | null disabled?: boolean @@ -170,6 +176,7 @@ export function FileUpload({ maxSize = 10, // Default 10MB acceptedTypes = '*', multiple = false, // Default to single file for backward compatibility + requiresCloudStorage = false, isPreview = false, previewValue, disabled = false, @@ -212,6 +219,13 @@ export function FileUpload({ refetch: refetchWorkspaceFiles, } = useWorkspaceFiles(isPreview ? '' : workspaceId) + const { data: cloudConfigured, isLoading: loadingCloudStatus } = useCloudStorageConfigured( + requiresCloudStorage && !isPreview + ) + // Block new uploads until we know cloud is available (avoid creating unpublishable files). + const cloudUploadBlocked = + requiresCloudStorage && (loadingCloudStatus || cloudConfigured === false) + const uploadFileMutation = useUploadWorkspaceFile() const queryClient = useQueryClient() @@ -278,7 +292,7 @@ export function FileUpload({ e.preventDefault() e.stopPropagation() - if (disabled) return + if (disabled || cloudUploadBlocked) return if (fileInputRef.current) { fileInputRef.current.value = '' @@ -308,7 +322,7 @@ export function FileUpload({ * Handles file upload when new file(s) are selected */ const handleFileChange = async (e: React.ChangeEvent) => { - if (isPreview || disabled) return + if (isPreview || disabled || cloudUploadBlocked) return e.stopPropagation() @@ -601,7 +615,7 @@ export function FileUpload({ // Options for multiple file mode (filters out already selected files) const comboboxOptions = useMemo( () => [ - { label: 'Upload New File', value: '__upload_new__' }, + { label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked }, ...availableWorkspaceFiles.map((file) => { const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) @@ -612,13 +626,13 @@ export function FileUpload({ } }), ], - [availableWorkspaceFiles, acceptedTypes] + [availableWorkspaceFiles, acceptedTypes, cloudUploadBlocked] ) // Options for single file mode (includes all files, selected one will be highlighted) const singleFileOptions = useMemo( () => [ - { label: 'Upload New File', value: '__upload_new__' }, + { label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked }, ...workspaceFiles.map((file) => { const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) @@ -629,7 +643,7 @@ export function FileUpload({ } }), ], - [workspaceFiles, acceptedTypes] + [workspaceFiles, acceptedTypes, cloudUploadBlocked] ) // Find the selected file's workspace ID for highlighting in single file mode @@ -667,6 +681,7 @@ export function FileUpload({ setInputValue('') if (value === '__upload_new__') { + if (cloudUploadBlocked) return handleOpenFileDialog({ preventDefault: () => {}, stopPropagation: () => {}, @@ -688,6 +703,13 @@ export function FileUpload({ data-testid='file-input-element' /> + {requiresCloudStorage && cloudConfigured === false && ( +
+ Cloud storage (S3 or Blob) is required for file uploads. Switch to advanced mode and paste + a public HTTPS URL, or configure S3_BUCKET_NAME / Azure Blob env vars. +
+ )} + {/* Error message */} {uploadError &&
{uploadError}
} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 01ea37d8706..4a3acf04bc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -904,6 +904,7 @@ function SubBlockComponent({ acceptedTypes={config.acceptedTypes || '*'} multiple={config.multiple === true} maxSize={config.maxSize} + requiresCloudStorage={config.requiresCloudStorage === true} isPreview={isPreview} previewValue={previewValue as any} disabled={isDisabled} diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index 75f97a8dd95..d9f346eb867 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -64,8 +64,7 @@ export const InstagramBlock: BlockConfig = { category: 'tools', integrationType: IntegrationType.Marketing, // Gradient fills the tile; white camera glyph sits on top (same pattern as AWS icons). - bgColor: - 'radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)', + bgColor: 'radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)', iconColor: '#E4405F', icon: InstagramIcon, subBlocks: [ @@ -130,6 +129,8 @@ export const InstagramBlock: BlockConfig = { canonicalParamId: 'image', placeholder: 'Upload a JPEG image to publish', acceptedTypes: '.jpg,.jpeg,image/jpeg', + maxSize: 8, + requiresCloudStorage: true, condition: { field: 'operation', value: 'instagram_publish_image' }, mode: 'basic', multiple: false, @@ -140,7 +141,7 @@ export const InstagramBlock: BlockConfig = { title: 'Image', type: 'short-input', canonicalParamId: 'image', - placeholder: 'Reference files from previous blocks (or paste a public HTTPS JPEG URL)', + 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' }, @@ -154,6 +155,8 @@ export const InstagramBlock: BlockConfig = { canonicalParamId: 'video', placeholder: 'Upload a video to publish', acceptedTypes: '.mp4,.mov,video/mp4,video/quicktime', + maxSize: 300, + requiresCloudStorage: true, condition: { field: 'operation', value: ['instagram_publish_video', 'instagram_publish_reel'], @@ -170,7 +173,7 @@ export const InstagramBlock: BlockConfig = { title: 'Video', type: 'short-input', canonicalParamId: 'video', - placeholder: 'Reference files from previous blocks (or paste a public HTTPS video URL)', + placeholder: 'Reference files from previous blocks, or paste a public HTTPS video URL', condition: { field: 'operation', value: ['instagram_publish_video', 'instagram_publish_reel'], @@ -188,6 +191,8 @@ export const InstagramBlock: BlockConfig = { canonicalParamId: 'cover', placeholder: 'Upload a JPEG cover image', acceptedTypes: '.jpg,.jpeg,image/jpeg', + maxSize: 8, + requiresCloudStorage: true, condition: { field: 'operation', value: ['instagram_publish_video', 'instagram_publish_reel'], @@ -201,7 +206,7 @@ export const InstagramBlock: BlockConfig = { title: 'Cover Image', type: 'short-input', canonicalParamId: 'cover', - placeholder: 'Reference files from previous blocks (or paste a public HTTPS JPEG URL)', + placeholder: 'Reference files from previous blocks, or paste a public HTTPS JPEG URL', condition: { field: 'operation', value: ['instagram_publish_video', 'instagram_publish_reel'], @@ -218,6 +223,8 @@ export const InstagramBlock: BlockConfig = { canonicalParamId: 'media', placeholder: 'Upload a JPEG image or MP4/MOV video for the story', acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', + maxSize: 100, + requiresCloudStorage: true, condition: { field: 'operation', value: 'instagram_publish_story' }, mode: 'basic', multiple: false, @@ -228,7 +235,7 @@ export const InstagramBlock: BlockConfig = { title: 'Media', type: 'short-input', canonicalParamId: 'media', - placeholder: 'Reference files from previous blocks (or paste a public HTTPS URL)', + 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' }, @@ -242,6 +249,8 @@ export const InstagramBlock: BlockConfig = { canonicalParamId: 'carouselMedia', placeholder: 'Upload 2-10 images/videos to publish', acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', + maxSize: 300, + requiresCloudStorage: true, condition: { field: 'operation', value: 'instagram_publish_carousel' }, mode: 'basic', multiple: true, @@ -311,10 +320,11 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, title: 'Share to Feed', type: 'dropdown', options: [ - { label: 'No', id: 'false' }, + // Yes first: Meta shares reels to the feed by default when the param is omitted. { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, ], - value: () => 'false', + value: () => 'true', mode: 'advanced', condition: { field: 'operation', value: 'instagram_publish_reel' }, }, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 7e3f9636bef..8bd5175d50e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -375,6 +375,11 @@ export interface SubBlockConfig { acceptedTypes?: string multiple?: boolean maxSize?: number + /** + * When true, FileUpload checks for S3/Blob and warns / disables new uploads if missing. + * Used by providers (e.g. Instagram) that need a Meta-fetchable public HTTPS URL. + */ + requiresCloudStorage?: boolean // Slider-specific properties step?: number integer?: boolean diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index edd65bb3984..66e588989df 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -52,12 +52,15 @@ export const workspaceFilesKeys = { ...(storageKey ? [storageKey] : []), ] as const, storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const, + cloudConfigured: () => [...workspaceFilesKeys.all, 'cloudConfigured'] as const, } export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000 export const WORKSPACE_STORAGE_INFO_STALE_TIME = 60 * 1000 +/** Cloud storage (S3/Blob) is env-driven and does not change at runtime. */ +export const CLOUD_STORAGE_CONFIGURED_STALE_TIME = Number.POSITIVE_INFINITY /** * Storage info type @@ -285,6 +288,30 @@ export function useStorageInfo(enabled = true) { }) } +async function fetchCloudStorageConfigured(signal?: AbortSignal): Promise { + // boundary-raw-fetch: tiny status endpoint, no shared contract yet + const response = await fetch('/api/files/storage-status', { signal, cache: 'no-store' }) + if (!response.ok) { + throw new Error('Failed to check cloud storage status') + } + const data = (await response.json()) as { cloudConfigured?: boolean } + return data.cloudConfigured === true +} + +/** + * Whether S3 or Azure Blob is configured. Used by file uploads that need a + * publicly fetchable HTTPS URL (e.g. Instagram publish). + */ +export function useCloudStorageConfigured(enabled = true) { + return useQuery({ + queryKey: workspaceFilesKeys.cloudConfigured(), + queryFn: ({ signal }) => fetchCloudStorageConfigured(signal), + enabled, + retry: false, + staleTime: CLOUD_STORAGE_CONFIGURED_STALE_TIME, + }) +} + /** * Upload workspace file mutation */ diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/lib/integrations/instagram/resolve-media.ts index 139068d7fce..732eed7fdaa 100644 --- a/apps/sim/lib/integrations/instagram/resolve-media.ts +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -22,7 +22,7 @@ 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.' + 'Cloud storage is required to publish uploaded Instagram media. Configure S3_BUCKET_NAME and AWS_REGION (or Azure Blob AZURE_STORAGE_* vars), or paste a public HTTPS URL instead.' export type InstagramMediaRole = 'image' | 'video' | 'cover' | 'story' | 'carousel' diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 880a4802bc4..92ea5bc3342 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-07-10", + "updatedAt": "2026-07-12", "integrations": [ { "type": "onepassword", @@ -8936,7 +8936,7 @@ "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", + "bgColor": "radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)", "iconName": "InstagramIcon", "docsUrl": "https://docs.sim.ai/integrations/instagram", "operations": [ diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts index 954a883b7f5..eb1997bfb36 100644 --- a/apps/sim/tools/instagram/get_media.ts +++ b/apps/sim/tools/instagram/get_media.ts @@ -2,7 +2,7 @@ import type { InstagramGetMediaParams, InstagramGetMediaResponse } from '@/tools import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' -const DEFAULT_FIELDS = +const MEDIA_FIELDS = 'id,caption,media_type,media_product_type,media_url,permalink,timestamp,like_count,comments_count,children{id}' export const instagramGetMediaTool: ToolConfig = @@ -30,17 +30,10 @@ export const instagramGetMediaTool: ToolConfig - graphUrl(`/${params.mediaId.trim()}`, { fields: params.fields || DEFAULT_FIELDS }), + url: (params) => graphUrl(`/${params.mediaId.trim()}`, { fields: MEDIA_FIELDS }), method: 'GET', headers: (params) => bearerHeaders(params.accessToken), }, diff --git a/apps/sim/tools/instagram/list_media.ts b/apps/sim/tools/instagram/list_media.ts index c353d9a438d..02860922163 100644 --- a/apps/sim/tools/instagram/list_media.ts +++ b/apps/sim/tools/instagram/list_media.ts @@ -2,7 +2,7 @@ import type { InstagramListMediaParams, InstagramListMediaResponse } from '@/too import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' -const DEFAULT_MEDIA_FIELDS = +const MEDIA_FIELDS = 'id,caption,media_type,media_product_type,media_url,permalink,timestamp,like_count,comments_count' export const instagramListMediaTool: ToolConfig< @@ -44,19 +44,13 @@ export const instagramListMediaTool: ToolConfig< 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, + fields: MEDIA_FIELDS, limit: String(clampGraphLimit(params.limit)), after: params.after?.trim() || undefined, }) diff --git a/apps/sim/tools/instagram/private_reply.ts b/apps/sim/tools/instagram/private_reply.ts index 842818d1996..8e0a11d4bd8 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, idString, readGraphError } from '@/tools/instagram/utils' +import { graphUrl, idString, jsonBearerHeaders, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPrivateReplyTool: ToolConfig< @@ -52,7 +52,7 @@ export const instagramPrivateReplyTool: ToolConfig< return graphUrl(path) }, method: 'POST', - headers: (params) => bearerHeaders(params.accessToken), + headers: (params) => jsonBearerHeaders(params.accessToken), body: (params) => ({ recipient: { comment_id: params.commentId.trim() }, message: { text: params.message }, diff --git a/apps/sim/tools/instagram/publish_carousel.ts b/apps/sim/tools/instagram/publish_carousel.ts index 1987d71a0d9..ee245950e59 100644 --- a/apps/sim/tools/instagram/publish_carousel.ts +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -2,6 +2,7 @@ import type { InstagramPublishCarouselParams, InstagramPublishResponse, } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishCarouselTool: ToolConfig< @@ -60,24 +61,7 @@ export const instagramPublishCarouselTool: ToolConfig< }), }, - transformResponse: async (response) => { - const data = await response.json() - if (!response.ok || data.success === false) { - return { - success: false, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - error: data.error || 'Failed to publish carousel', - } - } - return { - success: true, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - } - }, + transformResponse: createPublishTransform('Failed to publish carousel'), - 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 }, - }, + outputs: PUBLISH_OUTPUTS, } diff --git a/apps/sim/tools/instagram/publish_image.ts b/apps/sim/tools/instagram/publish_image.ts index edf03061fbe..de299085171 100644 --- a/apps/sim/tools/instagram/publish_image.ts +++ b/apps/sim/tools/instagram/publish_image.ts @@ -1,4 +1,5 @@ import type { InstagramPublishImageParams, InstagramPublishResponse } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishImageTool: ToolConfig< @@ -71,24 +72,7 @@ export const instagramPublishImageTool: ToolConfig< }), }, - transformResponse: async (response) => { - const data = await response.json() - if (!response.ok || data.success === false) { - return { - success: false, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - error: data.error || 'Failed to publish image', - } - } - return { - success: true, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - } - }, + transformResponse: createPublishTransform('Failed to publish image'), - 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 }, - }, + outputs: PUBLISH_OUTPUTS, } diff --git a/apps/sim/tools/instagram/publish_reel.ts b/apps/sim/tools/instagram/publish_reel.ts index 4791aec07b0..db492275999 100644 --- a/apps/sim/tools/instagram/publish_reel.ts +++ b/apps/sim/tools/instagram/publish_reel.ts @@ -1,4 +1,5 @@ import type { InstagramPublishReelParams, InstagramPublishResponse } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishReelTool: ToolConfig< @@ -78,24 +79,7 @@ export const instagramPublishReelTool: ToolConfig< }), }, - transformResponse: async (response) => { - const data = await response.json() - if (!response.ok || data.success === false) { - return { - success: false, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - error: data.error || 'Failed to publish reel', - } - } - return { - success: true, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - } - }, + transformResponse: createPublishTransform('Failed to publish reel'), - 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 }, - }, + outputs: PUBLISH_OUTPUTS, } diff --git a/apps/sim/tools/instagram/publish_story.ts b/apps/sim/tools/instagram/publish_story.ts index efb6ab28926..f1a4b1424ec 100644 --- a/apps/sim/tools/instagram/publish_story.ts +++ b/apps/sim/tools/instagram/publish_story.ts @@ -1,4 +1,5 @@ import type { InstagramPublishResponse, InstagramPublishStoryParams } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishStoryTool: ToolConfig< @@ -49,24 +50,7 @@ export const instagramPublishStoryTool: ToolConfig< }), }, - transformResponse: async (response) => { - const data = await response.json() - if (!response.ok || data.success === false) { - return { - success: false, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - error: data.error || 'Failed to publish story', - } - } - return { - success: true, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - } - }, + transformResponse: createPublishTransform('Failed to publish story'), - 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 }, - }, + outputs: PUBLISH_OUTPUTS, } diff --git a/apps/sim/tools/instagram/publish_video.ts b/apps/sim/tools/instagram/publish_video.ts index 579f5c9665c..14d53157c6c 100644 --- a/apps/sim/tools/instagram/publish_video.ts +++ b/apps/sim/tools/instagram/publish_video.ts @@ -1,4 +1,5 @@ import type { InstagramPublishResponse, InstagramPublishVideoParams } from '@/tools/instagram/types' +import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishVideoTool: ToolConfig< @@ -64,24 +65,7 @@ export const instagramPublishVideoTool: ToolConfig< }), }, - transformResponse: async (response) => { - const data = await response.json() - if (!response.ok || data.success === false) { - return { - success: false, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - error: data.error || 'Failed to publish video', - } - } - return { - success: true, - output: data.output || { containerId: null, mediaId: null, statusCode: null }, - } - }, + transformResponse: createPublishTransform('Failed to publish video'), - 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 }, - }, + outputs: PUBLISH_OUTPUTS, } diff --git a/apps/sim/tools/instagram/send_text_message.ts b/apps/sim/tools/instagram/send_text_message.ts index 974d33d476e..647b42957d8 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, idString, readGraphError } from '@/tools/instagram/utils' +import { graphUrl, idString, jsonBearerHeaders, readGraphError } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramSendTextMessageTool: ToolConfig< @@ -53,7 +53,7 @@ export const instagramSendTextMessageTool: ToolConfig< return graphUrl(path) }, method: 'POST', - headers: (params) => bearerHeaders(params.accessToken), + headers: (params) => jsonBearerHeaders(params.accessToken), body: (params) => ({ recipient: { id: params.recipientId.trim() }, message: { text: params.message }, @@ -64,7 +64,7 @@ export const instagramSendTextMessageTool: ToolConfig< if (!response.ok) { return { success: false, - output: { messageId: null, recipientId: params?.recipientId.trim() ?? null }, + output: { messageId: null, recipientId: params?.recipientId?.trim() ?? null }, error: await readGraphError(response), } } @@ -77,7 +77,7 @@ export const instagramSendTextMessageTool: ToolConfig< success: true, output: { messageId: idString(data.message_id), - recipientId: idString(data.recipient_id) ?? params?.recipientId.trim() ?? null, + recipientId: idString(data.recipient_id) ?? params?.recipientId?.trim() ?? null, }, } }, diff --git a/apps/sim/tools/instagram/types.ts b/apps/sim/tools/instagram/types.ts index c2a3c8fcc6b..ad141d62882 100644 --- a/apps/sim/tools/instagram/types.ts +++ b/apps/sim/tools/instagram/types.ts @@ -26,7 +26,6 @@ export interface InstagramGetProfileResponse extends ToolResponse { export interface InstagramListMediaParams extends InstagramAccessParams { limit?: number after?: string - fields?: string } export interface InstagramListMediaResponse extends ToolResponse { @@ -49,7 +48,6 @@ export interface InstagramListMediaResponse extends ToolResponse { export interface InstagramGetMediaParams { accessToken: string mediaId: string - fields?: string } export interface InstagramGetMediaResponse extends ToolResponse { diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index ad2a96b22bb..065359f7e93 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -1,7 +1,16 @@ import { sleep } from '@sim/utils/helpers' import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram' +import type { InstagramPublishResponse } from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' export function bearerHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + } +} + +/** For the messaging endpoints, which take a JSON body (publish endpoints are form-encoded). */ +export function jsonBearerHeaders(accessToken: string): Record { return { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', @@ -187,6 +196,28 @@ export async function publishMediaContainer( return id } +/** Shared output schema for the five publish tools (image, video, reel, story, carousel). */ +export const PUBLISH_OUTPUTS: ToolConfig['outputs'] = { + containerId: { type: 'string', description: 'Media container id', optional: true }, + mediaId: { type: 'string', description: 'Published media id', optional: true }, + statusCode: { type: 'string', description: 'Final container status', optional: true }, +} + +/** + * Shared transformResponse for the publish tools, which all proxy through + * internal API routes returning `{ success, output, error }`. + */ +export function createPublishTransform(fallbackError: string) { + return async (response: Response): Promise => { + const data = await response.json() + const output = data.output || { containerId: null, mediaId: null, statusCode: null } + if (!response.ok || data.success === false) { + return { success: false, output, error: data.error || fallbackError } + } + return { success: true, output } + } +} + export function parseCommaSeparated(value?: string): string[] { if (!value) return [] return value From f8c951637ef3598013ccdcd9fce7cb7984f6bdf5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 11 Jul 2026 17:33:32 -0700 Subject: [PATCH 10/16] fix(instagram): fail closed when cloud storage status is unknown Treat loading/error as blocked for requiresCloudStorage uploads, show the warning once the check finishes, and disable selecting local workspace files Meta cannot fetch. Co-authored-by: Cursor --- .../components/file-upload/file-upload.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 12c863e91d0..a4f81a23aaf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -222,9 +222,11 @@ export function FileUpload({ const { data: cloudConfigured, isLoading: loadingCloudStatus } = useCloudStorageConfigured( requiresCloudStorage && !isPreview ) - // Block new uploads until we know cloud is available (avoid creating unpublishable files). - const cloudUploadBlocked = - requiresCloudStorage && (loadingCloudStatus || cloudConfigured === false) + // Fail closed: block until the status check succeeds with true. Loading, errors, and + // explicit false all leave cloudConfigured !== true (avoid Meta-unfetchable files). + const cloudUploadBlocked = requiresCloudStorage && cloudConfigured !== true + const showCloudStorageWarning = + requiresCloudStorage && !loadingCloudStatus && cloudConfigured !== true const uploadFileMutation = useUploadWorkspaceFile() const queryClient = useQueryClient() @@ -473,6 +475,8 @@ export function FileUpload({ * Handle selecting an existing workspace file */ const handleSelectWorkspaceFile = (fileId: string) => { + if (cloudUploadBlocked) return + const selectedFile = workspaceFiles.find((f) => f.id === fileId) if (!selectedFile) return @@ -622,7 +626,8 @@ export function FileUpload({ return { label: file.name, value: file.id, - disabled: !isAccepted, + // When cloud is required, local workspace files are also unpublishable. + disabled: !isAccepted || cloudUploadBlocked, } }), ], @@ -639,7 +644,7 @@ export function FileUpload({ return { label: file.name, value: file.id, - disabled: !isAccepted, + disabled: !isAccepted || cloudUploadBlocked, } }), ], @@ -703,7 +708,7 @@ export function FileUpload({ data-testid='file-input-element' /> - {requiresCloudStorage && cloudConfigured === false && ( + {showCloudStorageWarning && (
Cloud storage (S3 or Blob) is required for file uploads. Switch to advanced mode and paste a public HTTPS URL, or configure S3_BUCKET_NAME / Azure Blob env vars. From 6128a1888e71ea7c6651ca60427bcfd58825e2da Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 11 Jul 2026 17:38:24 -0700 Subject: [PATCH 11/16] fix(instagram): proactively refresh long-lived tokens before expiry Meta only allows refreshing still-valid Instagram tokens, so refresh within 14 days of expiry (after the 24h age gate) instead of waiting until after accessTokenExpiresAt. Co-authored-by: Cursor --- apps/sim/app/api/auth/oauth/utils.ts | 54 ++++++++++++++++++++---- apps/sim/lib/oauth/index.ts | 1 + apps/sim/lib/oauth/instagram.test.ts | 62 ++++++++++++++++++++++++++++ apps/sim/lib/oauth/instagram.ts | 38 +++++++++++++++++ 4 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 apps/sim/lib/oauth/instagram.test.ts create mode 100644 apps/sim/lib/oauth/instagram.ts diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 7574b789443..8d9f327a34a 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -8,6 +8,7 @@ import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' import { refreshOAuthToken } from '@/lib/oauth' +import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram' import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider, @@ -450,6 +451,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise accessTokenExpiresAt: account.accessTokenExpiresAt, idToken: account.idToken, scope: account.scope, + updatedAt: account.updatedAt, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) @@ -463,19 +465,33 @@ export async function getOAuthToken(userId: string, providerId: string): Promise const credential = connections[0] - // Determine whether we should refresh: missing token OR expired token + // Determine whether we should refresh: missing/expired token, or Instagram + // long-lived token nearing expiry (Meta cannot refresh after expiry). const now = new Date() const tokenExpiry = credential.accessTokenExpiresAt - const shouldAttemptRefresh = + const accessTokenNeedsRefresh = !!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now)) + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: credential.accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) - if (shouldAttemptRefresh) { - return performCoalescedRefresh({ + if (accessTokenNeedsRefresh || instagramNeedsProactiveRefresh) { + const fresh = await performCoalescedRefresh({ accountId: credential.id, providerId, refreshToken: credential.refreshToken!, userId, }) + if (fresh) return fresh + if (!accessTokenNeedsRefresh && credential.accessToken) { + return credential.accessToken + } + return null } if (!credential.accessToken) { @@ -550,7 +566,18 @@ export async function refreshAccessTokenIfNeeded( refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold - const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh + // Instagram long-lived tokens can only be refreshed while still valid. + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(credential.providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) + + const shouldRefresh = + accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh const accessToken = credential.accessToken @@ -567,8 +594,8 @@ export async function refreshAccessTokenIfNeeded( }) if (fresh) return fresh - // If refresh was only triggered proactively (Microsoft refresh-token aging), - // the still-valid access token is a fine fallback. + // If refresh was only triggered proactively (Microsoft refresh-token aging / + // Instagram long-lived nearing expiry), the still-valid access token is fine. if (!accessTokenNeedsRefresh && accessToken) { logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`) return accessToken @@ -616,7 +643,18 @@ export async function refreshTokenIfNeeded( refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold - const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh + // Instagram long-lived tokens can only be refreshed while still valid. + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(credential.providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) + + const shouldRefresh = + accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh // If token appears valid and present, return it directly if (!shouldRefresh) { diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts index f4c641730ea..8e075a51fb8 100644 --- a/apps/sim/lib/oauth/index.ts +++ b/apps/sim/lib/oauth/index.ts @@ -1,3 +1,4 @@ +export * from './instagram' export * from './microsoft' export * from './oauth' export * from './types' diff --git a/apps/sim/lib/oauth/instagram.test.ts b/apps/sim/lib/oauth/instagram.test.ts new file mode 100644 index 00000000000..f767f20011e --- /dev/null +++ b/apps/sim/lib/oauth/instagram.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + INSTAGRAM_MIN_TOKEN_AGE_MS, + INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS, + isInstagramProvider, + shouldProactivelyRefreshInstagramToken, +} from '@/lib/oauth/instagram' + +describe('instagram oauth helpers', () => { + it('identifies the instagram provider', () => { + expect(isInstagramProvider('instagram')).toBe(true) + expect(isInstagramProvider('facebook')).toBe(false) + }) + + it('does not refresh when the token is already expired', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date(now.getTime() - 60_000), + updatedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + now, + }) + ).toBe(false) + }) + + it('does not refresh tokens younger than 24 hours', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000), + updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS + 60_000), + now, + }) + ).toBe(false) + }) + + it('refreshes when expiry is within the proactive window and the token is old enough', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date( + now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS - 1) * 24 * 60 * 60 * 1000 + ), + updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS - 60_000), + now, + }) + ).toBe(true) + }) + + it('does not refresh when plenty of lifetime remains', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date( + now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS + 5) * 24 * 60 * 60 * 1000 + ), + updatedAt: new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000), + now, + }) + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/oauth/instagram.ts b/apps/sim/lib/oauth/instagram.ts new file mode 100644 index 00000000000..3fe5be1c35a --- /dev/null +++ b/apps/sim/lib/oauth/instagram.ts @@ -0,0 +1,38 @@ +/** Refresh while the long-lived token still has this many days left. */ +export const INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS = 14 + +/** + * Meta rejects refresh until the long-lived token is at least 24 hours old. + * @see https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token/ + */ +export const INSTAGRAM_MIN_TOKEN_AGE_MS = 24 * 60 * 60 * 1000 + +export function isInstagramProvider(providerId: string): boolean { + return providerId === 'instagram' +} + +/** + * Whether an Instagram long-lived token should be refreshed before it expires. + * Meta cannot refresh expired tokens, so we must refresh while still valid. + */ +export function shouldProactivelyRefreshInstagramToken(options: { + accessTokenExpiresAt?: Date | null + updatedAt?: Date | null + now?: Date +}): boolean { + const now = options.now ?? new Date() + const expiresAt = options.accessTokenExpiresAt + if (!expiresAt || expiresAt <= now) { + return false + } + + const updatedAt = options.updatedAt + if (updatedAt && now.getTime() - updatedAt.getTime() < INSTAGRAM_MIN_TOKEN_AGE_MS) { + return false + } + + const proactiveThreshold = new Date( + now.getTime() + INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 + ) + return expiresAt <= proactiveThreshold +} From 7a6f33afb95f6a60bf1d5534eb50a1e66635469e Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 11 Jul 2026 17:49:36 -0700 Subject: [PATCH 12/16] fix(oauth): restore TikTok clientIdParamName JSDoc after merge The staging merge dropped the opening /** on ProviderAuthConfig.clientIdParamName, which broke TypeScript parse in CI. Co-authored-by: Cursor --- apps/sim/lib/oauth/oauth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index eb63ab19c5f..20876727b0c 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1112,6 +1112,7 @@ interface ProviderAuthConfig { * standard OAuth refresh_token POST. */ refreshStrategy?: 'standard' | 'instagram_long_lived' + /** * Body param name to use for the client identifier instead of the standard `client_id`. * TikTok requires `client_key` instead. */ From 89caa680750d3bd27b7be68abf1a13bb5f9ce77c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 11 Jul 2026 17:52:44 -0700 Subject: [PATCH 13/16] fix(api): Zod-contract storage-status and ratchet validation baseline Wire /api/files/storage-status through a shared route contract so the strict API validation audit stays at zero non-Zod routes after the new Instagram cloud-storage check. Co-authored-by: Cursor --- apps/sim/app/api/files/storage-status/route.ts | 9 +++++++-- apps/sim/hooks/queries/workspace-files.ts | 8 ++------ apps/sim/lib/api/contracts/storage-transfer.ts | 12 ++++++++++++ scripts/check-api-validation-contracts.ts | 6 +++--- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/files/storage-status/route.ts b/apps/sim/app/api/files/storage-status/route.ts index ee4e82ba2a1..58dcadbdeb4 100644 --- a/apps/sim/app/api/files/storage-status/route.ts +++ b/apps/sim/app/api/files/storage-status/route.ts @@ -1,4 +1,6 @@ -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' +import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { hasCloudStorage } from '@/lib/uploads/core/storage-service' @@ -9,11 +11,14 @@ export const dynamic = 'force-dynamic' * GET /api/files/storage-status * Whether S3 or Azure Blob is configured (needed for Instagram file-upload publish). */ -export const GET = withRouteHandler(async () => { +export const GET = withRouteHandler(async (request: NextRequest) => { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const parsed = await parseRequest(fileStorageStatusContract, request, {}) + if (!parsed.success) return parsed.response + return NextResponse.json({ cloudConfigured: hasCloudStorage() }) }) diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index d0203162ac7..bced075225d 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -6,6 +6,7 @@ import { backoffWithJitter } from '@sim/utils/retry' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiClientError, isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' +import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits' import { deleteWorkspaceFileContract, @@ -298,12 +299,7 @@ export function useStorageInfo(enabled = true) { } async function fetchCloudStorageConfigured(signal?: AbortSignal): Promise { - // boundary-raw-fetch: tiny status endpoint, no shared contract yet - const response = await fetch('/api/files/storage-status', { signal, cache: 'no-store' }) - if (!response.ok) { - throw new Error('Failed to check cloud storage status') - } - const data = (await response.json()) as { cloudConfigured?: boolean } + const data = await requestJson(fileStorageStatusContract, { signal }) return data.cloudConfigured === true } diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 4a421203e4a..7f49d9e797e 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -745,6 +745,18 @@ export const fileExportContract = defineRouteContract({ response: { mode: 'binary' }, }) +export const fileStorageStatusResponseSchema = z.object({ + cloudConfigured: z.boolean(), +}) + +export const fileStorageStatusContract = defineRouteContract({ + method: 'GET', + path: '/api/files/storage-status', + response: { mode: 'json', schema: fileStorageStatusResponseSchema }, +}) + +export type FileStorageStatusResponse = ContractJsonResponse + export type BoxUploadBody = ContractBodyInput export type BoxUploadResponse = ContractJsonResponse export type DropboxUploadBody = ContractBodyInput diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index d8694b3f6bc..c6f786704a6 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 927, - zodRoutes: 927, + totalRoutes: 935, + zodRoutes: 935, nonZodRoutes: 0, } as const @@ -25,7 +25,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookRawFetches: 0, clientSameOriginApiFetches: 0, doubleCasts: 8, - rawJsonReads: 8, + rawJsonReads: 6, untypedResponses: 0, annotationsMissingReason: 0, } as const From 24bdce9cd955f998273a0284efac165799edc876 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Sat, 11 Jul 2026 18:09:53 -0700 Subject: [PATCH 14/16] fix(instagram): match Gmail advanced media placeholders Drop public-URL paste hints from advanced fields and the cloud-storage banner so the UI mirrors Gmail attachments. Co-authored-by: Cursor --- apps/sim/.env.example | 2 +- .../components/file-upload/file-upload.tsx | 4 ++-- apps/sim/blocks/blocks/instagram.ts | 13 ++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 49551cabe03..bed4e2e3054 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -100,7 +100,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # INSTAGRAM_CLIENT_ID= # INSTAGRAM_CLIENT_SECRET= # Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL). -# Pasting a public HTTPS URL in advanced mode works without cloud storage. Gmail attachments do not need this. +# Gmail attachments do not need this. # Azure Blob Storage takes precedence over S3 if both are configured # AZURE_ACCOUNT_NAME= # Azure storage account name # AZURE_ACCOUNT_KEY= # Azure storage account key diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index a4f81a23aaf..4b0368813d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -710,8 +710,8 @@ export function FileUpload({ {showCloudStorageWarning && (
- Cloud storage (S3 or Blob) is required for file uploads. Switch to advanced mode and paste - a public HTTPS URL, or configure S3_BUCKET_NAME / Azure Blob env vars. + Cloud storage (S3 or Blob) is required for file uploads. Configure S3_BUCKET_NAME and + AWS_REGION, or Azure Blob env vars.
)} diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index d9f346eb867..54e3ffce4f9 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -121,7 +121,7 @@ export const InstagramBlock: BlockConfig = { required: true, }, - // Publish Image — JPEG upload / file ref / public HTTPS URL + // Publish Image — JPEG upload / file ref (same basic+advanced pattern as Gmail) { id: 'imageUpload', title: 'Image', @@ -141,7 +141,7 @@ export const InstagramBlock: BlockConfig = { title: 'Image', type: 'short-input', canonicalParamId: 'image', - placeholder: 'Reference files from previous blocks, or paste a public HTTPS JPEG URL', + placeholder: 'Reference files from previous blocks', condition: { field: 'operation', value: 'instagram_publish_image' }, mode: 'advanced', required: { field: 'operation', value: 'instagram_publish_image' }, @@ -173,7 +173,7 @@ export const InstagramBlock: BlockConfig = { title: 'Video', type: 'short-input', canonicalParamId: 'video', - placeholder: 'Reference files from previous blocks, or paste a public HTTPS video URL', + placeholder: 'Reference files from previous blocks', condition: { field: 'operation', value: ['instagram_publish_video', 'instagram_publish_reel'], @@ -206,7 +206,7 @@ export const InstagramBlock: BlockConfig = { title: 'Cover Image', type: 'short-input', canonicalParamId: 'cover', - placeholder: 'Reference files from previous blocks, or paste a public HTTPS JPEG URL', + placeholder: 'Reference files from previous blocks', condition: { field: 'operation', value: ['instagram_publish_video', 'instagram_publish_reel'], @@ -235,7 +235,7 @@ export const InstagramBlock: BlockConfig = { title: 'Media', type: 'short-input', canonicalParamId: 'media', - placeholder: 'Reference files from previous blocks, or paste a public HTTPS URL', + placeholder: 'Reference files from previous blocks', condition: { field: 'operation', value: 'instagram_publish_story' }, mode: 'advanced', required: { field: 'operation', value: 'instagram_publish_story' }, @@ -261,8 +261,7 @@ export const InstagramBlock: BlockConfig = { title: 'Media', type: 'long-input', canonicalParamId: 'carouselMedia', - placeholder: - 'Reference files from previous blocks, or paste comma-separated public HTTPS URLs (prefix videos with video:)', + placeholder: 'Reference files from previous blocks', condition: { field: 'operation', value: 'instagram_publish_carousel' }, mode: 'advanced', required: { field: 'operation', value: 'instagram_publish_carousel' }, From 0fa622c6a8e839a3e3370af47359cd3b07b22f22 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 13 Jul 2026 16:24:44 -0700 Subject: [PATCH 15/16] code review + hide from toolbar --- apps/docs/components/icons.tsx | 2 +- apps/docs/components/ui/icon-mapping.ts | 2 - .../docs/en/integrations/instagram.mdx | 655 ------------------ .../content/docs/en/integrations/meta.json | 1 - apps/sim/.env.example | 5 + .../auth/oauth2/callback/instagram/route.ts | 80 ++- .../instagram/download-media/route.test.ts | 351 ++++++++++ .../tools/instagram/download-media/route.ts | 375 ++++++++++ .../tools/instagram/publish-carousel/route.ts | 38 +- .../tools/instagram/publish-image/route.ts | 26 +- .../api/tools/instagram/publish-reel/route.ts | 26 +- .../tools/instagram/publish-story/route.ts | 26 +- .../tools/instagram/publish-video/route.ts | 27 +- .../tools/tiktok/publish-video/route.test.ts | 159 +++++ .../api/tools/tiktok/publish-video/route.ts | 207 ++---- .../tools/tiktok/publish-video/upload.test.ts | 195 ++++++ .../api/tools/tiktok/publish-video/upload.ts | 311 +++++++++ .../sim/app/api/webhooks/tiktok/route.test.ts | 26 +- apps/sim/app/api/webhooks/tiktok/route.ts | 15 +- .../background/tiktok-webhook-ingress.test.ts | 134 +++- apps/sim/background/tiktok-webhook-ingress.ts | 81 ++- apps/sim/blocks/blocks/instagram.test.ts | 44 ++ apps/sim/blocks/blocks/instagram.ts | 479 ++++++++++--- apps/sim/blocks/blocks/tiktok.test.ts | 58 ++ apps/sim/blocks/blocks/tiktok.ts | 307 ++------ apps/sim/components/icons.tsx | 2 +- .../utils/file-tool-processor.test.ts | 112 +++ .../sim/executor/utils/file-tool-processor.ts | 61 +- .../lib/api/contracts/tools/instagram.test.ts | 61 ++ apps/sim/lib/api/contracts/tools/instagram.ts | 157 ++++- apps/sim/lib/auth/auth.ts | 80 ++- apps/sim/lib/integrations/icon-mapping.ts | 2 - .../{instagram.ts => instagram/constants.ts} | 0 .../integrations/instagram/resolve-media.ts | 3 +- apps/sim/lib/integrations/integrations.json | 112 --- apps/sim/lib/oauth/oauth.test.ts | 18 + .../lib/uploads/utils/file-utils.server.ts | 7 +- .../webhooks/providers/tiktok-targets.test.ts | 106 ++- .../lib/webhooks/providers/tiktok-targets.ts | 45 +- apps/sim/tools/instagram/delete_comment.ts | 21 +- apps/sim/tools/instagram/download_media.ts | 139 ++++ .../tools/instagram/get_account_insights.ts | 14 +- .../tools/instagram/get_container_status.ts | 14 +- .../get_conversation_messages.test.ts | 181 +++++ .../instagram/get_conversation_messages.ts | 76 +- apps/sim/tools/instagram/get_media.ts | 40 +- .../sim/tools/instagram/get_media_insights.ts | 12 +- apps/sim/tools/instagram/get_message.ts | 38 +- apps/sim/tools/instagram/get_profile.ts | 20 +- .../tools/instagram/get_publishing_limit.ts | 21 +- apps/sim/tools/instagram/hide_comment.ts | 17 +- apps/sim/tools/instagram/index.ts | 1 + apps/sim/tools/instagram/list_comments.ts | 57 +- .../sim/tools/instagram/list_conversations.ts | 36 +- apps/sim/tools/instagram/list_media.ts | 53 +- apps/sim/tools/instagram/list_stories.ts | 58 +- apps/sim/tools/instagram/output-properties.ts | 75 ++ apps/sim/tools/instagram/private_reply.ts | 30 +- apps/sim/tools/instagram/publish_carousel.ts | 9 +- apps/sim/tools/instagram/publish_image.ts | 8 +- apps/sim/tools/instagram/publish_reel.ts | 8 +- apps/sim/tools/instagram/publish_story.ts | 11 +- apps/sim/tools/instagram/publish_video.ts | 8 +- apps/sim/tools/instagram/reply_to_comment.ts | 33 +- apps/sim/tools/instagram/send_text_message.ts | 28 +- .../tools/instagram/set_comments_enabled.ts | 17 +- apps/sim/tools/instagram/types.ts | 42 +- apps/sim/tools/instagram/utils.ts | 203 +++++- apps/sim/tools/registry.ts | 2 + apps/sim/tools/tiktok/api-schemas.test.ts | 67 ++ apps/sim/tools/tiktok/api-schemas.ts | 45 +- .../tools/tiktok/direct_post_video.test.ts | 32 + apps/sim/tools/tiktok/direct_post_video.ts | 33 +- apps/sim/tools/tiktok/get_post_status.test.ts | 11 + apps/sim/tools/tiktok/get_post_status.ts | 1 - apps/sim/tools/tiktok/get_user.test.ts | 29 + apps/sim/tools/tiktok/get_user.ts | 28 +- apps/sim/tools/tiktok/list_videos.ts | 22 +- .../tools/tiktok/query_creator_info.test.ts | 67 ++ apps/sim/tools/tiktok/query_creator_info.ts | 22 +- apps/sim/tools/tiktok/query_videos.ts | 16 +- apps/sim/tools/tiktok/types.ts | 9 +- apps/sim/tools/tiktok/utils.test.ts | 16 +- apps/sim/tools/tiktok/utils.ts | 55 +- apps/sim/tools/tiktok/videos.test.ts | 56 ++ apps/sim/triggers/tiktok/index.ts | 16 +- scripts/check-api-validation-contracts.ts | 4 +- 87 files changed, 4469 insertions(+), 1728 deletions(-) delete mode 100644 apps/docs/content/docs/en/integrations/instagram.mdx create mode 100644 apps/sim/app/api/tools/instagram/download-media/route.test.ts create mode 100644 apps/sim/app/api/tools/instagram/download-media/route.ts create mode 100644 apps/sim/app/api/tools/tiktok/publish-video/route.test.ts create mode 100644 apps/sim/app/api/tools/tiktok/publish-video/upload.test.ts create mode 100644 apps/sim/app/api/tools/tiktok/publish-video/upload.ts create mode 100644 apps/sim/blocks/blocks/instagram.test.ts create mode 100644 apps/sim/blocks/blocks/tiktok.test.ts create mode 100644 apps/sim/executor/utils/file-tool-processor.test.ts create mode 100644 apps/sim/lib/api/contracts/tools/instagram.test.ts rename apps/sim/lib/integrations/{instagram.ts => instagram/constants.ts} (100%) create mode 100644 apps/sim/tools/instagram/download_media.ts create mode 100644 apps/sim/tools/instagram/get_conversation_messages.test.ts create mode 100644 apps/sim/tools/instagram/output-properties.ts create mode 100644 apps/sim/tools/tiktok/api-schemas.test.ts create mode 100644 apps/sim/tools/tiktok/direct_post_video.test.ts create mode 100644 apps/sim/tools/tiktok/get_post_status.test.ts create mode 100644 apps/sim/tools/tiktok/get_user.test.ts create mode 100644 apps/sim/tools/tiktok/query_creator_info.test.ts create mode 100644 apps/sim/tools/tiktok/videos.test.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 321dc9ab8d7..d18862c51e0 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1467,7 +1467,7 @@ export function LinkedInIcon(props: SVGProps) { ) } -/** Placeholder Instagram glyph — replace with brand SVG from press kit when provided. */ +/** Instagram camera glyph rendered with the block's brand gradient background. */ export function InstagramIcon(props: SVGProps) { return ( = { imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, - instagram: InstagramIcon, instantly: InstantlyIcon, intercom: IntercomIcon, intercom_v2: IntercomIcon, diff --git a/apps/docs/content/docs/en/integrations/instagram.mdx b/apps/docs/content/docs/en/integrations/instagram.mdx deleted file mode 100644 index 29da3afe9f9..00000000000 --- a/apps/docs/content/docs/en/integrations/instagram.mdx +++ /dev/null @@ -1,655 +0,0 @@ ---- -title: Instagram -description: Publish content, moderate comments, and manage Instagram DMs ---- - -import { BlockInfoCard } from "@/components/ui/block-info-card" - - - -## Usage Instructions - -Integrate Instagram into workflows. Publish images, videos, Reels, stories, and carousels; moderate comments; send DMs; and pull account or media insights. - - - -## Actions - -### `instagram_get_profile` - -Get the connected Instagram professional account profile - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `userId` | string | Instagram professional account user_id | -| `id` | string | Graph object id | -| `username` | string | Instagram username | -| `name` | string | Display name | -| `accountType` | string | Business or Media_Creator | -| `profilePictureUrl` | string | Profile picture URL | -| `followersCount` | number | Follower count | -| `followsCount` | number | Following count | -| `mediaCount` | number | Media count | - -### `instagram_list_media` - -List recent media on the Instagram professional account - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `limit` | number | No | Max number of media items to return \(default 25, max 100\) | -| `after` | string | No | Pagination cursor from a previous list_media response | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | -| `nextCursor` | string | Pagination cursor for the next page | - -### `instagram_get_media` - -Get details for a specific Instagram media object - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `mediaId` | string | Yes | Instagram media id | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `id` | string | Media id | -| `caption` | string | Caption text | -| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | -| `mediaProductType` | string | Feed, Reels, or Stories product type | -| `mediaUrl` | string | CDN media URL when available | -| `permalink` | string | Permalink to the post | -| `timestamp` | string | ISO timestamp | -| `likeCount` | number | Like count | -| `commentsCount` | number | Comments count | -| `children` | json | Carousel child media ids | - -### `instagram_list_stories` - -List active stories on the Instagram professional account - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | - -### `instagram_publish_image` - -Create and publish a single JPEG image post from an uploaded file or public HTTPS URL (polls until the container is ready) - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `image` | file | Yes | JPEG image file or public HTTPS URL \(Meta will download it\) | -| `caption` | string | No | Post caption \(max 2200 characters\) | -| `altText` | string | No | Accessibility alt text for the image | -| `isAiGenerated` | boolean | No | Mark the post as AI-generated | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `userId` | string | Instagram professional account user_id | -| `id` | string | Graph object or message id | -| `username` | string | Instagram username | -| `name` | string | Display name | -| `accountType` | string | Business or Media_Creator | -| `profilePictureUrl` | string | Profile picture URL | -| `followersCount` | number | Follower count | -| `followsCount` | number | Following count | -| `mediaCount` | number | Media count | -| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | -| `caption` | string | Media caption text | -| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | -| `mediaProductType` | string | Feed, Reels, or Stories product type | -| `mediaUrl` | string | CDN media URL when available | -| `permalink` | string | Permalink to the post | -| `timestamp` | string | ISO timestamp | -| `likeCount` | number | Like count | -| `commentsCount` | number | Comments count | -| `children` | json | Carousel child media ids | -| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | -| `containerId` | string | Media container id | -| `mediaId` | string | Published media id | -| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | -| `status` | string | Detailed container status message | -| `quotaUsage` | number | Publishes used in the current window | -| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | -| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | -| `success` | boolean | Whether the operation succeeded | -| `conversations` | json | Conversations \(id, updatedTime\) | -| `conversationId` | string | Conversation id | -| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | -| `createdTime` | string | Message created timestamp | -| `fromId` | string | Sender Instagram-scoped id | -| `fromUsername` | string | Sender username | -| `toId` | string | Recipient id | -| `message` | string | Message text | -| `messageId` | string | Sent or retrieved message id | -| `recipientId` | string | DM recipient id | -| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | -| `nextCursor` | string | Pagination cursor for the next page | -| `error` | string | Error message if the operation failed | - -### `instagram_publish_video` - -Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready) - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `video` | file | Yes | Video file or public HTTPS URL | -| `caption` | string | No | Post caption | -| `cover` | file | No | Optional JPEG cover image file or public HTTPS URL | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `userId` | string | Instagram professional account user_id | -| `id` | string | Graph object or message id | -| `username` | string | Instagram username | -| `name` | string | Display name | -| `accountType` | string | Business or Media_Creator | -| `profilePictureUrl` | string | Profile picture URL | -| `followersCount` | number | Follower count | -| `followsCount` | number | Following count | -| `mediaCount` | number | Media count | -| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | -| `caption` | string | Media caption text | -| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | -| `mediaProductType` | string | Feed, Reels, or Stories product type | -| `mediaUrl` | string | CDN media URL when available | -| `permalink` | string | Permalink to the post | -| `timestamp` | string | ISO timestamp | -| `likeCount` | number | Like count | -| `commentsCount` | number | Comments count | -| `children` | json | Carousel child media ids | -| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | -| `containerId` | string | Media container id | -| `mediaId` | string | Published media id | -| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | -| `status` | string | Detailed container status message | -| `quotaUsage` | number | Publishes used in the current window | -| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | -| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | -| `success` | boolean | Whether the operation succeeded | -| `conversations` | json | Conversations \(id, updatedTime\) | -| `conversationId` | string | Conversation id | -| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | -| `createdTime` | string | Message created timestamp | -| `fromId` | string | Sender Instagram-scoped id | -| `fromUsername` | string | Sender username | -| `toId` | string | Recipient id | -| `message` | string | Message text | -| `messageId` | string | Sent or retrieved message id | -| `recipientId` | string | DM recipient id | -| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | -| `nextCursor` | string | Pagination cursor for the next page | -| `error` | string | Error message if the operation failed | - -### `instagram_publish_reel` - -Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready) - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `video` | file | Yes | Reel video file or public HTTPS URL | -| `caption` | string | No | Reel caption | -| `cover` | file | No | Optional JPEG cover image file or public HTTPS URL | -| `shareToFeed` | boolean | No | Also share the Reel to the main feed | -| `thumbOffset` | number | No | Frame offset in milliseconds for the cover thumbnail | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `userId` | string | Instagram professional account user_id | -| `id` | string | Graph object or message id | -| `username` | string | Instagram username | -| `name` | string | Display name | -| `accountType` | string | Business or Media_Creator | -| `profilePictureUrl` | string | Profile picture URL | -| `followersCount` | number | Follower count | -| `followsCount` | number | Following count | -| `mediaCount` | number | Media count | -| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | -| `caption` | string | Media caption text | -| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | -| `mediaProductType` | string | Feed, Reels, or Stories product type | -| `mediaUrl` | string | CDN media URL when available | -| `permalink` | string | Permalink to the post | -| `timestamp` | string | ISO timestamp | -| `likeCount` | number | Like count | -| `commentsCount` | number | Comments count | -| `children` | json | Carousel child media ids | -| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | -| `containerId` | string | Media container id | -| `mediaId` | string | Published media id | -| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | -| `status` | string | Detailed container status message | -| `quotaUsage` | number | Publishes used in the current window | -| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | -| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | -| `success` | boolean | Whether the operation succeeded | -| `conversations` | json | Conversations \(id, updatedTime\) | -| `conversationId` | string | Conversation id | -| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | -| `createdTime` | string | Message created timestamp | -| `fromId` | string | Sender Instagram-scoped id | -| `fromUsername` | string | Sender username | -| `toId` | string | Recipient id | -| `message` | string | Message text | -| `messageId` | string | Sent or retrieved message id | -| `recipientId` | string | DM recipient id | -| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | -| `nextCursor` | string | Pagination cursor for the next page | -| `error` | string | Error message if the operation failed | - -### `instagram_publish_story` - -Publish an image or video story from an uploaded file or public HTTPS URL - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `media` | file | Yes | JPEG image or MP4/MOV video file, or a public HTTPS URL | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `userId` | string | Instagram professional account user_id | -| `id` | string | Graph object or message id | -| `username` | string | Instagram username | -| `name` | string | Display name | -| `accountType` | string | Business or Media_Creator | -| `profilePictureUrl` | string | Profile picture URL | -| `followersCount` | number | Follower count | -| `followsCount` | number | Following count | -| `mediaCount` | number | Media count | -| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | -| `caption` | string | Media caption text | -| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | -| `mediaProductType` | string | Feed, Reels, or Stories product type | -| `mediaUrl` | string | CDN media URL when available | -| `permalink` | string | Permalink to the post | -| `timestamp` | string | ISO timestamp | -| `likeCount` | number | Like count | -| `commentsCount` | number | Comments count | -| `children` | json | Carousel child media ids | -| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | -| `containerId` | string | Media container id | -| `mediaId` | string | Published media id | -| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | -| `status` | string | Detailed container status message | -| `quotaUsage` | number | Publishes used in the current window | -| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | -| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | -| `success` | boolean | Whether the operation succeeded | -| `conversations` | json | Conversations \(id, updatedTime\) | -| `conversationId` | string | Conversation id | -| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | -| `createdTime` | string | Message created timestamp | -| `fromId` | string | Sender Instagram-scoped id | -| `fromUsername` | string | Sender username | -| `toId` | string | Recipient id | -| `message` | string | Message text | -| `messageId` | string | Sent or retrieved message id | -| `recipientId` | string | DM recipient id | -| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | -| `nextCursor` | string | Pagination cursor for the next page | -| `error` | string | Error message if the operation failed | - -### `instagram_publish_carousel` - -Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `media` | file[] | Yes | 2-10 media files, or a comma-separated public URL string \(prefix videos with video:\) | -| `caption` | string | No | Carousel caption | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `userId` | string | Instagram professional account user_id | -| `id` | string | Graph object or message id | -| `username` | string | Instagram username | -| `name` | string | Display name | -| `accountType` | string | Business or Media_Creator | -| `profilePictureUrl` | string | Profile picture URL | -| `followersCount` | number | Follower count | -| `followsCount` | number | Following count | -| `mediaCount` | number | Media count | -| `media` | json | List of media objects \(id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount\) | -| `caption` | string | Media caption text | -| `mediaType` | string | IMAGE, VIDEO, or CAROUSEL_ALBUM | -| `mediaProductType` | string | Feed, Reels, or Stories product type | -| `mediaUrl` | string | CDN media URL when available | -| `permalink` | string | Permalink to the post | -| `timestamp` | string | ISO timestamp | -| `likeCount` | number | Like count | -| `commentsCount` | number | Comments count | -| `children` | json | Carousel child media ids | -| `stories` | json | Active stories \(id, mediaType, mediaUrl, timestamp\) | -| `containerId` | string | Media container id | -| `mediaId` | string | Published media id | -| `statusCode` | string | Container status \(EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED\) | -| `status` | string | Detailed container status message | -| `quotaUsage` | number | Publishes used in the current window | -| `config` | json | Publishing quota config \(quotaTotal, quotaDuration\) | -| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | -| `success` | boolean | Whether the operation succeeded | -| `conversations` | json | Conversations \(id, updatedTime\) | -| `conversationId` | string | Conversation id | -| `messages` | json | Message references \(id, createdTime\); use Get Message for sender, recipient, and text | -| `createdTime` | string | Message created timestamp | -| `fromId` | string | Sender Instagram-scoped id | -| `fromUsername` | string | Sender username | -| `toId` | string | Recipient id | -| `message` | string | Message text | -| `messageId` | string | Sent or retrieved message id | -| `recipientId` | string | DM recipient id | -| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | -| `nextCursor` | string | Pagination cursor for the next page | -| `error` | string | Error message if the operation failed | - -### `instagram_get_container_status` - -Check the publishing status of a media container - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `containerId` | string | Yes | Media container id returned from a create/publish step | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `containerId` | string | Container id | -| `statusCode` | string | EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED | -| `status` | string | Detailed status message when available | - -### `instagram_get_publishing_limit` - -Check the content publishing rate limit usage for the account - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `quotaUsage` | number | Number of publishes used in the current window | -| `config` | json | Quota config \(quotaTotal, quotaDuration\) | - -### `instagram_list_comments` - -List comments on an Instagram media object - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `mediaId` | string | Yes | Instagram media id | -| `limit` | number | No | Max number of comments to return \(default 25, max 100\) | -| `after` | string | No | Pagination cursor | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `comments` | json | Comments \(id, text, username, timestamp, likeCount, hidden\) | -| `nextCursor` | string | Pagination cursor | - -### `instagram_reply_to_comment` - -Reply to a comment on Instagram media - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `commentId` | string | Yes | Comment id to reply to | -| `message` | string | Yes | Reply text | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `id` | string | Created reply comment id | - -### `instagram_hide_comment` - -Hide or unhide a comment on Instagram media - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `commentId` | string | Yes | Comment id | -| `hide` | boolean | Yes | True to hide, false to unhide | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `success` | boolean | Whether the hide/unhide succeeded | - -### `instagram_delete_comment` - -Delete a comment on Instagram media - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `commentId` | string | Yes | Comment id to delete | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `success` | boolean | Whether the delete succeeded | - -### `instagram_set_comments_enabled` - -Enable or disable comments on an Instagram media object - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `mediaId` | string | Yes | Instagram media id | -| `commentEnabled` | boolean | Yes | True to enable comments, false to disable | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `success` | boolean | Whether the update succeeded | - -### `instagram_private_reply` - -Send a private Direct message reply to a commenter (one per commenter) - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `commentId` | string | Yes | Comment id to privately reply to | -| `message` | string | Yes | Private reply text | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `messageId` | string | Sent message id | -| `recipientId` | string | Instagram-scoped recipient id | - -### `instagram_list_conversations` - -List Instagram Direct conversations for the professional account - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `limit` | number | No | Max number of conversations to return \(default 25, max 100\) | -| `after` | string | No | Pagination cursor | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `conversations` | json | Conversations \(id, updatedTime\) | -| `nextCursor` | string | Pagination cursor | - -### `instagram_get_conversation_messages` - -List recent message ids/content in a conversation (only the last ~20 messages are fetchable) - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `conversationId` | string | Yes | Conversation id from list_conversations | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `conversationId` | string | Conversation id | -| `messages` | json | Message references \(id, createdTime\). Use Get Message for sender, recipient, and text. | - -### `instagram_get_message` - -Get a single Instagram Direct message by id (only recent messages are available) - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `messageId` | string | Yes | Message id | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `id` | string | Message id | -| `createdTime` | string | Created timestamp | -| `fromId` | string | Sender Instagram-scoped id | -| `fromUsername` | string | Sender username | -| `toId` | string | Recipient id | -| `message` | string | Message text | - -### `instagram_send_text_message` - -Send a text Direct message. The recipient must have messaged the account first (24h window). - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `recipientId` | string | Yes | Instagram-scoped user id \(IGSID\) of the recipient | -| `message` | string | Yes | Message text \(max 1000 bytes UTF-8\) | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `messageId` | string | Sent message id | -| `recipientId` | string | Recipient id | - -### `instagram_get_account_insights` - -Get insights metrics for the Instagram professional account - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `igUserId` | string | No | Instagram professional account user id \(defaults to /me\) | -| `metrics` | string | Yes | Comma-separated metrics \(e.g. reach,views,accounts_engaged,profile_views,follower_count\) | -| `period` | string | Yes | Aggregation period: day, week, days_28, lifetime, or total_over_range | -| `since` | string | No | Unix timestamp or date for range start | -| `until` | string | No | Unix timestamp or date for range end | -| `metricType` | string | No | Optional metric_type \(e.g. time_series, total_value\) | -| `breakdown` | string | No | Optional breakdown dimension | -| `timeframe` | string | No | Required lookback window for demographic metrics | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `insights` | json | Insight metrics \(name, period, title, description, values, totalValue\) | - -### `instagram_get_media_insights` - -Get insights metrics for a specific Instagram media object - -#### Input - -| Parameter | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `mediaId` | string | Yes | Instagram media id | -| `metrics` | string | Yes | Comma-separated metrics \(e.g. views,reach,likes,comments,saved,shares,total_interactions\) | - -#### Output - -| Parameter | Type | Description | -| --------- | ---- | ----------- | -| `insights` | json | Media insight metrics \(name, period, title, description, values, totalValue\) | - - diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 553a8c58770..d972f633cfc 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -113,7 +113,6 @@ "imap", "incidentio", "infisical", - "instagram", "instantly", "intercom", "jina", diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 4061785493a..f46957ed851 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -101,6 +101,11 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # INSTAGRAM_CLIENT_SECRET= # Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL). # Gmail attachments do not need this. + +# TikTok OAuth (Optional - Client Key/Secret from the TikTok for Developers app) +# TIKTOK_CLIENT_ID= +# TIKTOK_CLIENT_SECRET= + # Azure Blob Storage takes precedence over S3 if both are configured # AZURE_ACCOUNT_NAME= # Azure storage account name # AZURE_ACCOUNT_KEY= # Azure storage account key diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 42e56365b5c..556290bfd1d 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -7,11 +7,16 @@ 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 { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' 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 { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' @@ -25,8 +30,6 @@ const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' interface ShortLivedTokenPayload { access_token?: string user_id?: string | number - // Meta returns granted permissions as a comma-separated string or an array - // depending on the response shape. permissions?: string | string[] } @@ -35,7 +38,6 @@ function unwrapShortLivedToken(body: unknown): ShortLivedTokenPayload | 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') { @@ -43,7 +45,6 @@ function unwrapShortLivedToken(body: unknown): ShortLivedTokenPayload | null { } } - // Flat shape fallback if (typeof record.access_token === 'string') { return record as ShortLivedTokenPayload } @@ -109,7 +110,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - // Instagram appends `#_` to the redirect; strip it from the code if present. const authorizationCode = code.replace(/#_$/, '') const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram` @@ -125,20 +125,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenForm.toString(), + signal: request.signal, }) if (!shortLivedResponse.ok) { - const errorText = await shortLivedResponse.text() + const errorText = await readResponseTextWithLimit(shortLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth token error response', + signal: request.signal, + }).catch(() => shortLivedResponse.statusText) logger.error('Failed to exchange Instagram authorization code', { status: shortLivedResponse.status, - body: errorText, + error: errorText, }) return clearOAuthCookies( NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`) ) } - const shortLivedBody = await shortLivedResponse.json() + const shortLivedBody = await readResponseJsonWithLimit(shortLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth token response', + signal: request.signal, + }) const shortLived = unwrapShortLivedToken(shortLivedBody) if (!shortLived?.access_token) { logger.error('Instagram short-lived token response missing access_token', { @@ -154,23 +163,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => { exchangeUrl.searchParams.set('client_secret', clientSecret) exchangeUrl.searchParams.set('access_token', shortLived.access_token) - const longLivedResponse = await fetch(exchangeUrl.toString(), { method: 'GET' }) + const longLivedResponse = await fetch(exchangeUrl.toString(), { + method: 'GET', + signal: request.signal, + }) if (!longLivedResponse.ok) { - const errorText = await longLivedResponse.text() + const errorText = await readResponseTextWithLimit(longLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth exchange error response', + signal: request.signal, + }).catch(() => longLivedResponse.statusText) logger.error('Failed to exchange Instagram short-lived token', { status: longLivedResponse.status, - body: errorText, + error: errorText, }) return clearOAuthCookies( NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`) ) } - const longLivedBody = (await longLivedResponse.json()) as { + const longLivedBody = await readResponseJsonWithLimit<{ access_token?: string token_type?: string expires_in?: number - } + }>(longLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth exchange response', + signal: request.signal, + }) if (!longLivedBody.access_token) { logger.error('Instagram long-lived token response missing access_token', { @@ -184,33 +204,39 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const longLivedToken = longLivedBody.access_token const expiresIn = longLivedBody.expires_in ?? 5184000 - const profileResponse = await fetch( - `${INSTAGRAM_GRAPH_BASE}/me?fields=user_id,username,name,account_type,profile_picture_url&access_token=${encodeURIComponent(longLivedToken)}` - ) + const profileUrl = new URL(`${INSTAGRAM_GRAPH_BASE}/me`) + profileUrl.searchParams.set('fields', 'user_id,username,name,account_type,profile_picture_url') + const profileResponse = await fetch(profileUrl, { + headers: { Authorization: `Bearer ${longLivedToken}` }, + signal: request.signal, + }) if (!profileResponse.ok) { - const errorText = await profileResponse.text() + const errorText = await readResponseTextWithLimit(profileResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth profile error response', + signal: request.signal, + }).catch(() => profileResponse.statusText) logger.error('Failed to fetch Instagram profile after OAuth', { status: profileResponse.status, - body: errorText, + error: errorText, }) return clearOAuthCookies( NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) ) } - const profile = (await profileResponse.json()) as { + const profile = await readResponseJsonWithLimit<{ user_id?: string | number id?: string username?: string name?: string - } + }>(profileResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth profile response', + signal: request.signal, + }) - // account.accountId must always be the Instagram professional account ID - // (/me user_id, which Meta may serialize as a string or number). The token - // exchange's user_id and /me's id are app-scoped IDs — a different ID - // space — so falling back to them would break reconnect dedupe by storing - // the same account under two identifiers. const igUserId = profile.user_id != null && profile.user_id !== '' ? String(profile.user_id) : null @@ -236,8 +262,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { grantedPermissions.length > 0 ? grantedPermissions : getCanonicalScopesForProvider('instagram') - // Space-joined: the standard OAuth format both credential routes parse - // (`/api/auth/oauth/connections` splits on whitespace only). const scope = permissions.join(' ') const now = new Date() diff --git a/apps/sim/app/api/tools/instagram/download-media/route.test.ts b/apps/sim/app/api/tools/instagram/download-media/route.test.ts new file mode 100644 index 00000000000..52c49ef80b1 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/download-media/route.test.ts @@ -0,0 +1,351 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDeleteFileMetadata, + mockDeleteFiles, + mockDownloadFileFromUrl, + mockUploadExecutionFile, + mockUploadCopilotFile, +} = vi.hoisted(() => ({ + mockDeleteFileMetadata: vi.fn(), + mockDeleteFiles: vi.fn(), + mockDownloadFileFromUrl: vi.fn(), + mockUploadExecutionFile: vi.fn(), + mockUploadCopilotFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromUrl: mockDownloadFileFromUrl, +})) +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mockUploadExecutionFile, +})) +vi.mock('@/lib/uploads/contexts/copilot', () => ({ + uploadCopilotFile: mockUploadCopilotFile, +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + deleteFiles: mockDeleteFiles, +})) +vi.mock('@/lib/uploads/server/metadata', () => ({ + deleteFileMetadata: mockDeleteFileMetadata, +})) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { POST } from '@/app/api/tools/instagram/download-media/route' +import { instagramDownloadMediaTool } from '@/tools/instagram/download_media' + +const mockFetch = vi.fn() +const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0x01]) + +function executionFile(name: string, type: string, size: number) { + return { + id: `file-${name}`, + name, + url: `/api/files/serve/execution/${name}`, + size, + type, + key: `execution/workflow-1/execution-1/${name}`, + context: 'execution', + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockDownloadFileFromUrl.mockResolvedValue(Buffer.from('instagram-media')) + mockDeleteFiles.mockResolvedValue({ deleted: 0, failed: [] }) + mockDeleteFileMetadata.mockResolvedValue(true) + mockUploadExecutionFile.mockImplementation( + async ( + _context: { workspaceId: string; workflowId: string; executionId: string }, + buffer: Buffer, + name: string, + type: string + ) => executionFile(name, type, buffer.length) + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('POST /api/tools/instagram/download-media', () => { + it('stores a single download as an execution-scoped UserFile', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + id: 'media-1', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/media-1.jpg', + }) + ) + mockDownloadFileFromUrl.mockResolvedValueOnce(JPEG_BYTES) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'media-1', + filename: 'campaign-cover.png', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toEqual({ + success: true, + output: { + files: [executionFile('campaign-cover.jpg', 'image/jpeg', JPEG_BYTES.length)], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }, + }) + expect(mockDownloadFileFromUrl).toHaveBeenCalledWith( + 'https://scontent.example.com/media-1.jpg', + expect.objectContaining({ + maxBytes: MAX_FILE_SIZE, + signal: expect.any(AbortSignal), + userId: 'user-1', + }) + ) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + JPEG_BYTES, + 'campaign-cover.jpg', + 'image/jpeg', + 'user-1' + ) + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + }) + + it('downloads carousel children sequentially and preserves their order', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ + id: 'carousel-1', + media_type: 'CAROUSEL_ALBUM', + children: { data: [{ id: 'child-image' }, { id: 'child-video' }] }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + id: 'child-image', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/child-image.jpg', + }) + ) + .mockResolvedValueOnce( + Response.json({ + id: 'child-video', + media_type: 'VIDEO', + media_url: 'https://scontent.example.com/child-video.mp4', + }) + ) + mockDownloadFileFromUrl + .mockResolvedValueOnce(JPEG_BYTES) + .mockResolvedValueOnce(Buffer.from('video')) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'carousel-1', + filename: 'launch', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.output).toEqual({ + files: [ + executionFile('launch-1.jpg', 'image/jpeg', JPEG_BYTES.length), + executionFile('launch-2.mp4', 'video/mp4', 5), + ], + mediaId: 'carousel-1', + mediaType: 'CAROUSEL_ALBUM', + downloadedCount: 2, + }) + expect(mockDownloadFileFromUrl.mock.calls.map(([url]) => url)).toEqual([ + 'https://scontent.example.com/child-image.jpg', + 'https://scontent.example.com/child-video.mp4', + ]) + expect(mockUploadExecutionFile.mock.calls.map(([, , name]) => name)).toEqual([ + 'launch-1.jpg', + 'launch-2.mp4', + ]) + expect(mockUploadExecutionFile.mock.invocationCallOrder[0]).toBeLessThan( + mockFetch.mock.invocationCallOrder[2] + ) + }) + + it('rolls back earlier carousel files when a later child cannot be downloaded', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ + id: 'carousel-1', + media_type: 'CAROUSEL_ALBUM', + children: { data: [{ id: 'child-image' }, { id: 'child-missing' }] }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + id: 'child-image', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/child-image.jpg', + }) + ) + .mockResolvedValueOnce( + Response.json( + { error: { message: 'The second carousel item is unavailable' } }, + { status: 404 } + ) + ) + mockDownloadFileFromUrl.mockResolvedValueOnce(JPEG_BYTES) + mockDeleteFiles.mockResolvedValueOnce({ deleted: 1, failed: [] }) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'carousel-1', + filename: 'launch', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + const storedFile = executionFile('launch-1.jpg', 'image/jpeg', JPEG_BYTES.length) + expect(response.status).toBe(404) + expect(mockDeleteFiles).toHaveBeenCalledWith([storedFile.key], 'execution') + expect(mockDeleteFileMetadata).toHaveBeenCalledWith(storedFile.key) + }) + + it('does not preserve an image MIME type when the downloaded bytes are not a raster image', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + id: 'media-invalid-image', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/media-invalid-image.jpg', + }) + ) + const invalidImage = Buffer.from('not an image') + mockDownloadFileFromUrl.mockResolvedValueOnce(invalidImage) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'media-invalid-image', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + expect(response.status).toBe(200) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + expect.any(Object), + invalidImage, + 'instagram-media-invalid-image.bin', + 'application/octet-stream', + 'user-1' + ) + }) + + it('returns 413 when a media download exceeds the size cap', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + id: 'media-large', + media_type: 'VIDEO', + media_url: 'https://scontent.example.com/media-large.mp4', + }) + ) + mockDownloadFileFromUrl.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'Instagram media download', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'media-large', + }) + ) + + expect(response.status).toBe(413) + expect(await response.json()).toMatchObject({ success: false }) + expect(mockUploadExecutionFile).not.toHaveBeenCalled() + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + }) +}) + +describe('instagramDownloadMediaTool', () => { + it('forwards execution context and returns canonical file-array output', async () => { + const body = instagramDownloadMediaTool.request.body?.({ + accessToken: 'instagram-token', + mediaId: 'media-1', + filename: 'campaign-cover', + _context: { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(body).toEqual({ + accessToken: 'instagram-token', + mediaId: 'media-1', + filename: 'campaign-cover', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + + const file = executionFile('campaign-cover.jpg', 'image/jpeg', 11) + const result = await instagramDownloadMediaTool.transformResponse?.( + Response.json({ + success: true, + output: { + files: [file], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }, + }), + { + accessToken: 'instagram-token', + mediaId: 'media-1', + } + ) + + expect(result).toEqual({ + success: true, + output: { + files: [file], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }, + }) + expect(instagramDownloadMediaTool.outputs?.files.type).toBe('file[]') + }) +}) diff --git a/apps/sim/app/api/tools/instagram/download-media/route.ts b/apps/sim/app/api/tools/instagram/download-media/route.ts new file mode 100644 index 00000000000..956398ddec5 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/download-media/route.ts @@ -0,0 +1,375 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { type NextRequest, NextResponse } from 'next/server' +import { + type InstagramDownloadMediaRouteResponse, + instagramDownloadMediaContract, + instagramDownloadMediaOutputSchema, +} from '@/lib/api/contracts/tools/instagram' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { deleteFiles } from '@/lib/uploads/core/storage-service' +import { deleteFileMetadata } from '@/lib/uploads/server/metadata' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { + getExtensionFromMimeType, + getFileExtension, + getMimeTypeFromExtension, +} from '@/lib/uploads/utils/file-utils' +import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' +import { MAX_FILE_SIZE, sniffImageContentType } from '@/lib/uploads/utils/validation' +import { sanitizeFileName } from '@/executor/constants' +import type { UserFile } from '@/executor/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' + +const logger = createLogger('InstagramDownloadMediaAPI') +const MAX_GRAPH_METADATA_BYTES = 256 * 1024 +const MAX_CAROUSEL_ITEMS = 10 +const ROOT_MEDIA_FIELDS = 'id,media_type,media_url,children{id}' +const CHILD_MEDIA_FIELDS = 'id,media_type,media_url' + +export const dynamic = 'force-dynamic' +export const maxDuration = 900 + +interface InstagramMediaMetadata { + id: string + mediaType: string | null + mediaUrl: string | null + childIds: string[] +} + +type InstagramMediaMetadataResult = + | { success: true; data: InstagramMediaMetadata } + | { success: false; error: string; status: number } + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies InstagramDownloadMediaRouteResponse + return NextResponse.json(body, { status }) +} + +function normalizedId(value: unknown): string | null { + return typeof value === 'string' || typeof value === 'number' ? idString(value) : null +} + +function parseMediaMetadata(data: unknown): InstagramMediaMetadataResult { + if (!isRecordLike(data)) { + return { success: false, error: 'Instagram returned invalid media metadata', status: 502 } + } + + const id = normalizedId(data.id) + if (!id) { + return { success: false, error: 'Instagram media metadata did not include an ID', status: 502 } + } + + const mediaType = typeof data.media_type === 'string' ? data.media_type : null + const mediaUrl = + typeof data.media_url === 'string' && data.media_url.length > 0 ? data.media_url : null + const children = data.children + + if (children === undefined) { + return { success: true, data: { id, mediaType, mediaUrl, childIds: [] } } + } + + if (!isRecordLike(children) || !Array.isArray(children.data)) { + return { success: false, error: 'Instagram returned invalid carousel metadata', status: 502 } + } + + if (children.data.length > MAX_CAROUSEL_ITEMS) { + return { + success: false, + error: `Instagram carousel exceeds the ${MAX_CAROUSEL_ITEMS}-item download limit`, + status: 502, + } + } + + const childIds: string[] = [] + for (const child of children.data) { + if (!isRecordLike(child)) { + return { success: false, error: 'Instagram returned an invalid carousel item', status: 502 } + } + const childId = normalizedId(child.id) + if (!childId) { + return { + success: false, + error: 'Instagram carousel item did not include an ID', + status: 502, + } + } + childIds.push(childId) + } + + return { success: true, data: { id, mediaType, mediaUrl, childIds } } +} + +async function fetchMediaMetadata({ + accessToken, + mediaId, + fields, + signal, +}: { + accessToken: string + mediaId: string + fields: string + signal: AbortSignal +}): Promise { + const response = await fetch(graphUrl(`/${encodeURIComponent(mediaId)}`, { fields }), { + headers: bearerHeaders(accessToken), + signal, + }) + + if (!response.ok) { + return { + success: false, + error: await readGraphError(response), + status: response.status >= 400 && response.status < 500 ? response.status : 502, + } + } + + const data = await readResponseJsonWithLimit(response, { + maxBytes: MAX_GRAPH_METADATA_BYTES, + label: `Instagram media ${mediaId} metadata`, + signal, + }) + return parseMediaMetadata(data) +} + +function inferContentType(mediaUrl: string, mediaType: string | null): string { + if (mediaType === 'VIDEO') return 'video/mp4' + if (mediaType === 'IMAGE') return 'image/jpeg' + + let extension = '' + try { + extension = getFileExtension(new URL(mediaUrl).pathname) + } catch { + extension = '' + } + + const mimeType = getMimeTypeFromExtension(extension) + if (mimeType !== 'application/octet-stream') return mimeType + return 'application/octet-stream' +} + +function resolveDownloadedContentType( + buffer: Buffer, + mediaUrl: string, + mediaType: string | null +): string { + const inferred = inferContentType(mediaUrl, mediaType) + if (mediaType === 'IMAGE' || inferred.startsWith('image/')) { + return sniffImageContentType(buffer) ?? 'application/octet-stream' + } + return inferred +} + +function buildFilename({ + filename, + mediaId, + contentType, + itemIndex, + itemCount, +}: { + filename?: string + mediaId: string + contentType: string + itemIndex: number + itemCount: number +}): string { + const extension = getExtensionFromMimeType(contentType) ?? 'bin' + if (!filename) return sanitizeFileName(`instagram-${mediaId}.${extension}`) + + const sanitized = sanitizeFileName(filename).replace(/^\.+/, '') + const lastDot = sanitized.lastIndexOf('.') + const base = (lastDot > 0 ? sanitized.slice(0, lastDot) : sanitized) || `instagram-${mediaId}` + const suffix = itemCount > 1 ? `-${itemIndex + 1}` : '' + return `${base}${suffix}.${extension}` +} + +async function downloadAndStoreMedia({ + metadata, + filename, + itemIndex, + itemCount, + userId, + executionContext, + signal, +}: { + metadata: InstagramMediaMetadata + filename?: string + itemIndex: number + itemCount: number + userId: string + executionContext?: { workspaceId: string; workflowId: string; executionId: string } + signal: AbortSignal +}): Promise { + if (!metadata.mediaUrl) { + throw new Error(`Instagram media ${metadata.id} did not include a downloadable URL`) + } + + const buffer = await downloadFileFromUrl(metadata.mediaUrl, { + maxBytes: MAX_FILE_SIZE, + signal, + userId, + }) + const contentType = resolveDownloadedContentType(buffer, metadata.mediaUrl, metadata.mediaType) + const storedFilename = buildFilename({ + filename, + mediaId: metadata.id, + contentType, + itemIndex, + itemCount, + }) + if (executionContext) { + return uploadExecutionFile(executionContext, buffer, storedFilename, contentType, userId) + } + + return uploadCopilotFile({ + buffer, + fileName: storedFilename, + contentType, + userId, + }) +} + +/** Removes successfully stored files when a multi-item download cannot return a complete result. */ +async function rollbackStoredFiles(files: UserFile[], context: StorageContext): Promise { + if (files.length === 0) return + + const keys = files.map((file) => file.key) + let failedKeys: Set + try { + const deletion = await deleteFiles(keys, context) + failedKeys = new Set(deletion.failed.map((failure) => failure.key)) + if (deletion.failed.length > 0) { + logger.warn('Instagram media rollback could not delete every stored object', { + context, + failedKeys: [...failedKeys], + }) + } + } catch (error) { + logger.warn('Instagram media rollback failed before metadata cleanup', { + context, + error: getErrorMessage(error), + keys, + }) + return + } + + for (const key of keys) { + if (failedKeys.has(key)) continue + try { + await deleteFileMetadata(key) + } catch (error) { + logger.warn('Instagram media rollback could not delete file metadata', { + error: getErrorMessage(error), + key, + }) + } + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return failureResponse(authResult.error || 'Unauthorized', 401) + } + + const parsed = await parseRequest( + instagramDownloadMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const files: UserFile[] = [] + let storageContext: StorageContext = 'copilot' + try { + const body = parsed.data.body + const rootResult = await fetchMediaMetadata({ + accessToken: body.accessToken, + mediaId: body.mediaId, + fields: ROOT_MEDIA_FIELDS, + signal: request.signal, + }) + if (!rootResult.success) return failureResponse(rootResult.error, rootResult.status) + + const rootMedia = rootResult.data + const itemCount = rootMedia.childIds.length || 1 + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : undefined + storageContext = executionContext ? 'execution' : 'copilot' + + if (rootMedia.childIds.length === 0) { + files.push( + await downloadAndStoreMedia({ + metadata: rootMedia, + filename: body.filename, + itemIndex: 0, + itemCount, + userId: authResult.userId, + executionContext, + signal: request.signal, + }) + ) + } else { + for (const [itemIndex, childId] of rootMedia.childIds.entries()) { + const childResult = await fetchMediaMetadata({ + accessToken: body.accessToken, + mediaId: childId, + fields: CHILD_MEDIA_FIELDS, + signal: request.signal, + }) + if (!childResult.success) { + await rollbackStoredFiles(files, storageContext) + return failureResponse(childResult.error, childResult.status) + } + + files.push( + await downloadAndStoreMedia({ + metadata: childResult.data, + filename: body.filename, + itemIndex, + itemCount, + userId: authResult.userId, + executionContext, + signal: request.signal, + }) + ) + } + } + + const output = instagramDownloadMediaOutputSchema.parse({ + files, + mediaId: rootMedia.id, + mediaType: rootMedia.mediaType, + downloadedCount: files.length, + }) + const responseBody = { + success: true, + output, + } satisfies InstagramDownloadMediaRouteResponse + + return NextResponse.json(responseBody) + } catch (error) { + await rollbackStoredFiles(files, storageContext) + logger.error('Instagram media download failed', { error }) + return failureResponse( + getErrorMessage(error, 'Failed to download Instagram media'), + isPayloadSizeLimitError(error) ? 413 : 500 + ) + } +}) 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 95fd745cbdc..0c5d0b6cfaa 100644 --- a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -57,12 +57,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const igUserId = await resolveIgUserId( + body.accessToken, + body.igUserId ?? undefined, + request.signal + ) - // 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 = { @@ -74,13 +74,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } else { childBody.image_url = item.url } - childIds.push(await createMediaContainer(body.accessToken, igUserId, childBody)) + childIds.push( + await createMediaContainer(body.accessToken, igUserId, childBody, request.signal) + ) } - // 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)) + childIds.map((childId) => waitForContainerReady(body.accessToken, childId, request.signal)) ) const failedChild = childResults.find( (result): result is PromiseRejectedResult => result.status === 'rejected' @@ -95,9 +95,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } 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) + const containerId = await createMediaContainer( + body.accessToken, + igUserId, + parentBody, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/instagram/publish-image/route.ts b/apps/sim/app/api/tools/instagram/publish-image/route.ts index 137c967e7a2..90cb8b62666 100644 --- a/apps/sim/app/api/tools/instagram/publish-image/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-image/route.ts @@ -56,7 +56,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const igUserId = await resolveIgUserId( + body.accessToken, + body.igUserId ?? undefined, + request.signal + ) const containerBody: Record = { image_url: resolved.media.url, } @@ -64,9 +68,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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) + const containerId = await createMediaContainer( + body.accessToken, + igUserId, + containerBody, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/instagram/publish-reel/route.ts b/apps/sim/app/api/tools/instagram/publish-reel/route.ts index 11f06f8720d..e59a2993489 100644 --- a/apps/sim/app/api/tools/instagram/publish-reel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-reel/route.ts @@ -80,7 +80,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { coverUrl = resolvedCover.media?.url } - const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const igUserId = await resolveIgUserId( + body.accessToken, + body.igUserId ?? undefined, + request.signal + ) const containerBody: Record = { media_type: 'REELS', video_url: resolvedVideo.media.url, @@ -92,9 +96,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } 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) + const containerId = await createMediaContainer( + body.accessToken, + igUserId, + containerBody, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/instagram/publish-story/route.ts b/apps/sim/app/api/tools/instagram/publish-story/route.ts index bc7ca629035..b80b90473fc 100644 --- a/apps/sim/app/api/tools/instagram/publish-story/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-story/route.ts @@ -56,7 +56,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const igUserId = await resolveIgUserId( + body.accessToken, + body.igUserId ?? undefined, + request.signal + ) const containerBody: Record = { media_type: 'STORIES', } @@ -66,9 +70,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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) + const containerId = await createMediaContainer( + body.accessToken, + igUserId, + containerBody, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/instagram/publish-video/route.ts b/apps/sim/app/api/tools/instagram/publish-video/route.ts index 4f4ca2cf224..dcc58c67870 100644 --- a/apps/sim/app/api/tools/instagram/publish-video/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-video/route.ts @@ -80,8 +80,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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 igUserId = await resolveIgUserId( + body.accessToken, + body.igUserId ?? undefined, + request.signal + ) const containerBody: Record = { media_type: 'REELS', video_url: resolvedVideo.media.url, @@ -90,9 +93,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { 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) + const containerId = await createMediaContainer( + body.accessToken, + igUserId, + containerBody, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) return NextResponse.json({ success: true, diff --git a/apps/sim/app/api/tools/tiktok/publish-video/route.test.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.test.ts new file mode 100644 index 00000000000..4bd583a2a92 --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/route.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' + +const { + mockAssertToolFileAccess, + mockComputeTikTokChunkPlan, + mockGetStoredVideoSize, + mockStreamStoredVideoToTikTok, +} = vi.hoisted(() => ({ + mockAssertToolFileAccess: vi.fn(), + mockComputeTikTokChunkPlan: vi.fn(() => ({ + chunkSize: 10_000_000, + totalChunkCount: 2, + })), + mockGetStoredVideoSize: vi.fn(), + mockStreamStoredVideoToTikTok: vi.fn(), +})) + +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: mockAssertToolFileAccess, +})) + +vi.mock('@/app/api/tools/tiktok/publish-video/upload', () => ({ + computeTikTokChunkPlan: mockComputeTikTokChunkPlan, + getStoredVideoSize: mockGetStoredVideoSize, + streamStoredVideoToTikTok: mockStreamStoredVideoToTikTok, + TIKTOK_MAX_VIDEO_BYTES: 250 * 1024 * 1024, +})) + +import { POST } from '@/app/api/tools/tiktok/publish-video/route' + +const file = { + key: 'workspace/workspace-1/video.mp4', + name: 'video.mp4', + size: 1, + type: 'video/mp4', +} + +describe('POST /api/tools/tiktok/publish-video', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockAssertToolFileAccess.mockResolvedValue(null) + mockGetStoredVideoSize.mockResolvedValue(20_000_000) + mockComputeTikTokChunkPlan.mockReturnValue({ + chunkSize: 10_000_000, + totalChunkCount: 2, + }) + mockStreamStoredVideoToTikTok.mockResolvedValue(undefined) + }) + + it('uses authoritative storage size for initialization and streaming', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + data: { publish_id: 'publish-1', upload_url: 'https://upload.example/video' }, + error: { code: 'ok' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + const request = createMockRequest('POST', { + accessToken: 'access-token', + mode: 'draft', + file, + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + output: { publishId: 'publish-1' }, + }) + expect(mockGetStoredVideoSize).toHaveBeenCalledWith({ + key: file.key, + context: 'workspace', + signal: request.signal, + }) + const init = JSON.parse(fetchMock.mock.calls[0][1]?.body as string) as { + source_info: Record + } + expect(init.source_info).toEqual({ + source: 'FILE_UPLOAD', + video_size: 20_000_000, + chunk_size: 10_000_000, + total_chunk_count: 2, + }) + expect(fetchMock.mock.calls[0][1]?.signal).toBe(request.signal) + expect(mockStreamStoredVideoToTikTok).toHaveBeenCalledWith({ + key: file.key, + context: 'workspace', + uploadUrl: 'https://upload.example/video', + totalBytes: 20_000_000, + mimeType: 'video/mp4', + requestId: 'mock-request-id', + signal: request.signal, + }) + }) + + it('keeps Direct Post unavailable until per-post approval exists', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'access-token', + mode: 'direct', + file, + musicUsageConsent: 'accepted', + postInfo: { + privacy_level: 'SELF_ONLY', + disable_duet: true, + disable_stitch: true, + disable_comment: true, + brand_content_toggle: false, + }, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: expect.stringMatching(/Direct Post is not available/), + }) + expect(mockGetStoredVideoSize).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 413 when the storage object exceeds the relay limit', async () => { + mockGetStoredVideoSize.mockRejectedValue( + new PayloadSizeLimitError({ + label: 'TikTok video upload', + maxBytes: 250 * 1024 * 1024, + observedBytes: 251 * 1024 * 1024, + }) + ) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'access-token', + mode: 'draft', + file, + }) + ) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Video exceeds the 250MB limit for file uploads.', + }) + }) +}) diff --git a/apps/sim/app/api/tools/tiktok/publish-video/route.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.ts index d60157807df..ec1cf21c55b 100644 --- a/apps/sim/app/api/tools/tiktok/publish-video/route.ts +++ b/apps/sim/app/api/tools/tiktok/publish-video/route.ts @@ -5,129 +5,38 @@ import { tiktokPublishVideoContract } from '@/lib/api/contracts/tiktok-tools' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { isPayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getFileExtension, getMimeTypeFromExtension, processSingleFileToUserFile, + resolveTrustedFileContext, } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + computeTikTokChunkPlan, + getStoredVideoSize, + streamStoredVideoToTikTok, + TIKTOK_MAX_VIDEO_BYTES, +} from '@/app/api/tools/tiktok/publish-video/upload' import type { UserFile } from '@/executor/types' import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas' import { readTikTokApiResponse } from '@/tools/tiktok/utils' export const dynamic = 'force-dynamic' +export const maxDuration = 900 const logger = createLogger('TikTokPublishVideoAPI') const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm']) -/** TikTok requires each chunk between 5MB and 64MB; the final chunk absorbs the remainder (up to ~2x this size, well under the 128MB cap). Capped at 1000 chunks total, which this default comfortably satisfies up to TikTok's 4GB video size limit. */ -const DEFAULT_CHUNK_SIZE = 10_000_000 -const TIKTOK_ERROR_RESPONSE_MAX_BYTES = 64 * 1024 - -/** Maximum size this route will buffer in memory for a single file-upload request. TikTok's - * own limit is 4GB, but relaying that much through this server's memory per request isn't - * safe under concurrent load. Enforced before downloading the file so an oversized upload - * fails fast with a clean 413 instead of materializing hundreds of MB to multiple GB - * in-process. */ -const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024 - -function computeChunkPlan(totalBytes: number): { chunkSize: number; totalChunkCount: number } { - if (totalBytes <= DEFAULT_CHUNK_SIZE) { - return { chunkSize: totalBytes, totalChunkCount: 1 } - } - const totalChunkCount = Math.floor(totalBytes / DEFAULT_CHUNK_SIZE) - return { chunkSize: DEFAULT_CHUNK_SIZE, totalChunkCount } -} - function resolveVideoMimeType(fileName: string, fileType: string | undefined): string | null { if (fileType && TIKTOK_VIDEO_MIME_TYPES.has(fileType)) return fileType const fromExtension = getMimeTypeFromExtension(getFileExtension(fileName)) return TIKTOK_VIDEO_MIME_TYPES.has(fromExtension) ? fromExtension : null } -async function validateDirectPostSettings( - accessToken: string, - postInfo: { privacy_level: string; brand_content_toggle: boolean }, - requestId: string -): Promise { - if (postInfo.brand_content_toggle && postInfo.privacy_level === 'SELF_ONLY') { - return 'Branded content cannot use Only Me privacy.' - } - - const response = await fetch('https://open.tiktokapis.com/v2/post/publish/creator_info/query/', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: '{}', - }) - const data = await response.json() - if (!response.ok || (data.error?.code && data.error.code !== 'ok')) { - logger.warn(`[${requestId}] TikTok creator-info preflight failed`, { - status: response.status, - code: data.error?.code, - }) - return data.error?.message || 'TikTok creator information could not be verified.' - } - - const privacyOptions: unknown = data.data?.privacy_level_options - if ( - !Array.isArray(privacyOptions) || - !privacyOptions.some((option) => option === postInfo.privacy_level) - ) { - return `The selected privacy level (${postInfo.privacy_level}) is not currently available for this TikTok account. Run Query Creator Info and choose one of the returned options.` - } - - return null -} - -async function uploadChunks( - uploadUrl: string, - buffer: Buffer, - mimeType: string, - requestId: string -): Promise { - const totalBytes = buffer.length - const { chunkSize, totalChunkCount } = computeChunkPlan(totalBytes) - - for (let i = 0; i < totalChunkCount; i++) { - const start = i * chunkSize - const isLastChunk = i === totalChunkCount - 1 - const end = isLastChunk ? totalBytes - 1 : start + chunkSize - 1 - const chunk = buffer.subarray(start, end + 1) - - const response = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Type': mimeType, - 'Content-Length': String(chunk.length), - 'Content-Range': `bytes ${start}-${end}/${totalBytes}`, - }, - body: new Uint8Array(chunk), - }) - - if (!response.ok) { - const errorText = await readResponseTextWithLimit(response, { - maxBytes: TIKTOK_ERROR_RESPONSE_MAX_BYTES, - label: 'TikTok upload error response', - }).catch(() => 'Error response exceeded the allowed size') - logger.error(`[${requestId}] TikTok chunk upload failed`, { - chunkIndex: i, - status: response.status, - errorText, - }) - throw new Error( - `TikTok rejected video chunk ${i + 1}/${totalChunkCount}: ${response.status} ${errorText || response.statusText}` - ) - } - } -} - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -145,6 +54,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const data = parsed.data.body + if (data.mode === 'direct') { + return NextResponse.json( + { + success: false, + error: + 'TikTok Direct Post is not available until per-post human approval is implemented. Use Upload Video Draft instead.', + }, + { status: 400 } + ) + } + let userFile: UserFile try { userFile = processSingleFileToUserFile(data.file, requestId, logger) @@ -158,17 +78,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) if (denied) return denied - if (data.mode === 'direct') { - const settingsError = await validateDirectPostSettings( - data.accessToken, - data.postInfo, - requestId - ) - if (settingsError) { - return NextResponse.json({ success: false, error: settingsError }, { status: 400 }) - } - } - const mimeType = resolveVideoMimeType(userFile.name, userFile.type) if (!mimeType) { return NextResponse.json( @@ -180,57 +89,58 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - logger.info(`[${requestId}] Downloading video from storage`, { - fileName: userFile.name, - size: userFile.size, - }) - - const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger, { - maxBytes: TIKTOK_MAX_VIDEO_BYTES, + const context = resolveTrustedFileContext(userFile.key, userFile.context) + const videoSize = await getStoredVideoSize({ + key: userFile.key, + context, + signal: request.signal, }) - if (fileBuffer.length === 0) { + if (videoSize === 0) { return NextResponse.json( { success: false, error: 'The video file is empty.' }, { status: 400 } ) } - const { chunkSize, totalChunkCount } = computeChunkPlan(fileBuffer.length) - const initUrl = - data.mode === 'draft' - ? 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/' - : 'https://open.tiktokapis.com/v2/post/publish/video/init/' + logger.info(`[${requestId}] Resolved video from storage`, { + fileName: userFile.name, + declaredSize: userFile.size, + storageSize: videoSize, + }) - const initBody: Record = { + const { chunkSize, totalChunkCount } = computeTikTokChunkPlan(videoSize) + const initBody = { source_info: { source: 'FILE_UPLOAD', - video_size: fileBuffer.length, + video_size: videoSize, chunk_size: chunkSize, total_chunk_count: totalChunkCount, }, } - if (data.mode === 'direct') { - initBody.post_info = data.postInfo - } - logger.info(`[${requestId}] Initializing TikTok video ${data.mode}`, { - videoSize: fileBuffer.length, + logger.info(`[${requestId}] Initializing TikTok video draft`, { + videoSize, chunkSize, totalChunkCount, }) - const initResponse = await fetch(initUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${data.accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: JSON.stringify(initBody), - }) + const initResponse = await fetch( + 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', + { + method: 'POST', + headers: { + Authorization: `Bearer ${data.accessToken}`, + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: JSON.stringify(initBody), + signal: request.signal, + } + ) const { data: initData, error: initError } = await readTikTokApiResponse( initResponse, - tiktokPublishInitApiDataSchema + tiktokPublishInitApiDataSchema, + { signal: request.signal } ) if (initError) { @@ -252,8 +162,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - await uploadChunks(uploadUrl, fileBuffer, mimeType, requestId) + await streamStoredVideoToTikTok({ + key: userFile.key, + context, + uploadUrl, + totalBytes: videoSize, + mimeType, + requestId, + signal: request.signal, + }) } catch (error) { + if (request.signal.aborted) throw error return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to upload video to TikTok') }, { status: 502 } @@ -278,6 +197,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 413 } ) } + if (request.signal.aborted) { + return NextResponse.json( + { success: false, error: 'TikTok video upload was cancelled.' }, + { status: 499 } + ) + } logger.error(`[${requestId}] Error publishing video to TikTok:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Internal server error') }, diff --git a/apps/sim/app/api/tools/tiktok/publish-video/upload.test.ts b/apps/sim/app/api/tools/tiktok/publish-video/upload.test.ts new file mode 100644 index 00000000000..bc4f402f176 --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/upload.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' + +const { mockBackoffWithJitter, mockDownloadFileStream, mockHeadObject, mockParseRetryAfter } = + vi.hoisted(() => ({ + mockBackoffWithJitter: vi.fn(() => 0), + mockDownloadFileStream: vi.fn(), + mockHeadObject: vi.fn(), + mockParseRetryAfter: vi.fn(() => 25), + })) + +vi.mock('@sim/utils/retry', () => ({ + backoffWithJitter: mockBackoffWithJitter, + parseRetryAfter: mockParseRetryAfter, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mockDownloadFileStream, + headObject: mockHeadObject, +})) + +import { + computeTikTokChunkPlan, + getStoredVideoSize, + streamStoredVideoToTikTok, + TIKTOK_MAX_VIDEO_BYTES, +} from '@/app/api/tools/tiktok/publish-video/upload' + +const baseStreamOptions = { + key: 'workspace/workspace-1/video.mp4', + context: 'workspace' as const, + uploadUrl: 'https://upload.example/video', + mimeType: 'video/mp4', + requestId: 'request-1', +} + +describe('TikTok video upload streaming', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBackoffWithJitter.mockReturnValue(0) + mockParseRetryAfter.mockReturnValue(25) + }) + + it('uses provider metadata as the authoritative bounded size', async () => { + mockHeadObject.mockResolvedValue({ size: 1234, contentType: 'video/mp4' }) + + await expect( + getStoredVideoSize({ + key: baseStreamOptions.key, + context: baseStreamOptions.context, + signal: new AbortController().signal, + }) + ).resolves.toBe(1234) + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) + + it('counts a stream without accumulating it when provider metadata is unavailable', async () => { + mockHeadObject.mockResolvedValue(null) + mockDownloadFileStream.mockResolvedValue( + Readable.from([Buffer.alloc(3), Buffer.alloc(5), Buffer.alloc(7)]) + ) + + await expect( + getStoredVideoSize({ + key: baseStreamOptions.key, + context: baseStreamOptions.context, + signal: new AbortController().signal, + }) + ).resolves.toBe(15) + }) + + it('rejects an oversized provider object before opening its body', async () => { + mockHeadObject.mockResolvedValue({ size: TIKTOK_MAX_VIDEO_BYTES + 1 }) + + await expect( + getStoredVideoSize({ + key: baseStreamOptions.key, + context: baseStreamOptions.context, + signal: new AbortController().signal, + }) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) + + it('computes TikTok chunk counts with the final chunk absorbing the remainder', () => { + expect(computeTikTokChunkPlan(4_000_000)).toEqual({ + chunkSize: 4_000_000, + totalChunkCount: 1, + }) + expect(computeTikTokChunkPlan(20_000_001)).toEqual({ + chunkSize: 10_000_000, + totalChunkCount: 2, + }) + }) + + it('streams sequential chunks with exact 206 intermediate and 201 final ranges', async () => { + const totalBytes = 20_000_001 + mockDownloadFileStream.mockResolvedValue( + Readable.from([Buffer.alloc(7_000_000, 1), Buffer.alloc(13_000_001, 2)]) + ) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 206 })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + + await streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes, + signal: controller.signal, + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'PUT', + signal: controller.signal, + headers: { + 'Content-Length': '10000000', + 'Content-Range': 'bytes 0-9999999/20000001', + 'Content-Type': 'video/mp4', + }, + }) + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + method: 'PUT', + signal: controller.signal, + headers: { + 'Content-Length': '10000001', + 'Content-Range': 'bytes 10000000-20000000/20000001', + 'Content-Type': 'video/mp4', + }, + }) + }) + + it('retries only 5xx responses with the same bounded chunk', async () => { + mockDownloadFileStream.mockResolvedValue(Readable.from([Buffer.from('video')])) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response('temporary one', { status: 500, headers: { 'Retry-After': '1' } }) + ) + .mockResolvedValueOnce(new Response('temporary two', { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + vi.stubGlobal('fetch', fetchMock) + + await streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes: 5, + signal: new AbortController().signal, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(mockParseRetryAfter).toHaveBeenCalledTimes(2) + expect(mockBackoffWithJitter).toHaveBeenNthCalledWith(1, 1, 25) + expect(mockBackoffWithJitter).toHaveBeenNthCalledWith(2, 2, 25) + const uploadedBodies = fetchMock.mock.calls.map((call) => + Buffer.from(call[1]?.body as Uint8Array).toString('utf8') + ) + expect(uploadedBodies).toEqual(['video', 'video', 'video']) + }) + + it('rejects a successful but protocol-invalid final status without retrying', async () => { + mockDownloadFileStream.mockResolvedValue(Readable.from([Buffer.from('video')])) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes: 5, + signal: new AbortController().signal, + }) + ).rejects.toThrow('expected HTTP 201, received HTTP 200') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('detects storage-size drift before sending the final chunk', async () => { + mockDownloadFileStream.mockResolvedValue(Readable.from([Buffer.from('video-extra')])) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect( + streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes: 5, + signal: new AbortController().signal, + }) + ).rejects.toThrow('Stored video grew after its size was resolved') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/tools/tiktok/publish-video/upload.ts b/apps/sim/app/api/tools/tiktok/publish-video/upload.ts new file mode 100644 index 00000000000..7ec6d5687fc --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/upload.ts @@ -0,0 +1,311 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { + assertKnownSizeWithinLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import type { StorageContext } from '@/lib/uploads/config' +import { downloadFileStream, headObject } from '@/lib/uploads/core/storage-service' + +const logger = createLogger('TikTokVideoUpload') + +/** TikTok accepts 5-64 MB chunks and allows the final chunk to absorb the remainder. */ +const DEFAULT_CHUNK_SIZE = 10_000_000 +const MAX_UPLOAD_ATTEMPTS = 3 +const ERROR_RESPONSE_MAX_BYTES = 64 * 1024 + +/** + * Sim intentionally applies a lower relay ceiling than TikTok's provider limit. This bounds + * request duration and the current-chunk retry buffer while uploads run in the web process. + */ +export const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024 + +export interface TikTokChunkPlan { + chunkSize: number + totalChunkCount: number +} + +interface StoredFileOptions { + key: string + context: StorageContext + signal: AbortSignal +} + +interface StreamStoredVideoOptions extends StoredFileOptions { + uploadUrl: string + totalBytes: number + mimeType: string + requestId: string +} + +function abortError(signal: AbortSignal): Error { + return toError(signal.reason ?? new Error('Request aborted')) +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw abortError(signal) +} + +function bindStreamToAbort(stream: Readable, signal: AbortSignal): () => void { + let handled = false + const onAbort = () => { + if (handled) return + handled = true + stream.destroy(abortError(signal)) + } + if (signal.aborted) { + onAbort() + } else { + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + } + return () => signal.removeEventListener('abort', onAbort) +} + +async function sleepWithAbort(delayMs: number, signal: AbortSignal): Promise { + throwIfAborted(signal) + + let onAbort: (() => void) | undefined + const aborted = new Promise((_, reject) => { + let handled = false + onAbort = () => { + if (handled) return + handled = true + reject(abortError(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + }) + + try { + await Promise.race([sleep(delayMs), aborted]) + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort) + } +} + +function normalizeStreamChunk(value: unknown): Buffer { + if (Buffer.isBuffer(value)) return value + if (value instanceof Uint8Array || typeof value === 'string') return Buffer.from(value) + throw new Error('Storage returned an unsupported video stream chunk') +} + +/** Computes TikTok's declared FILE_UPLOAD chunk layout without reading the file. */ +export function computeTikTokChunkPlan(totalBytes: number): TikTokChunkPlan { + if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) { + throw new Error('TikTok video size must be a positive safe integer') + } + + if (totalBytes <= DEFAULT_CHUNK_SIZE) { + return { chunkSize: totalBytes, totalChunkCount: 1 } + } + + return { + chunkSize: DEFAULT_CHUNK_SIZE, + totalChunkCount: Math.floor(totalBytes / DEFAULT_CHUNK_SIZE), + } +} + +function validateStoredSize(size: number): number { + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error('Storage returned an invalid video size') + } + assertKnownSizeWithinLimit(size, TIKTOK_MAX_VIDEO_BYTES, 'TikTok video upload') + return size +} + +async function countStoredFileBytes(options: StoredFileOptions): Promise { + throwIfAborted(options.signal) + const stream = await downloadFileStream({ key: options.key, context: options.context }) + const unbindAbort = bindStreamToAbort(stream, options.signal) + let totalBytes = 0 + + try { + for await (const value of stream) { + throwIfAborted(options.signal) + totalBytes += normalizeStreamChunk(value).byteLength + assertKnownSizeWithinLimit(totalBytes, TIKTOK_MAX_VIDEO_BYTES, 'TikTok video upload') + } + throwIfAborted(options.signal) + return validateStoredSize(totalBytes) + } finally { + unbindAbort() + stream.destroy() + } +} + +/** + * Resolves the authoritative object size. Cloud storage uses provider metadata; local storage + * and providers without HEAD support are counted with a bounded, zero-accumulation pass. + */ +export async function getStoredVideoSize(options: StoredFileOptions): Promise { + throwIfAborted(options.signal) + const metadata = await headObject(options.key, options.context) + throwIfAborted(options.signal) + + return metadata ? validateStoredSize(metadata.size) : countStoredFileBytes(options) +} + +class ExactStreamReader { + private readonly iterator: AsyncIterator + private pending: Buffer | null = null + private pendingOffset = 0 + + constructor(stream: Readable) { + this.iterator = stream[Symbol.asyncIterator]() + } + + private async nextNonEmptyChunk(signal: AbortSignal): Promise { + while (true) { + throwIfAborted(signal) + const next = await this.iterator.next() + throwIfAborted(signal) + if (next.done) return null + const chunk = normalizeStreamChunk(next.value) + if (chunk.byteLength > 0) return chunk + } + } + + async readExactly(byteLength: number, signal: AbortSignal): Promise { + const output = Buffer.allocUnsafe(byteLength) + let written = 0 + + while (written < byteLength) { + if (!this.pending || this.pendingOffset >= this.pending.byteLength) { + this.pending = await this.nextNonEmptyChunk(signal) + this.pendingOffset = 0 + if (!this.pending) { + throw new Error(`Stored video ended early: expected ${byteLength - written} more byte(s)`) + } + } + + const available = this.pending.byteLength - this.pendingOffset + const copyLength = Math.min(available, byteLength - written) + this.pending.copy(output, written, this.pendingOffset, this.pendingOffset + copyLength) + this.pendingOffset += copyLength + written += copyLength + } + + return output + } + + async assertExhausted(signal: AbortSignal): Promise { + if (this.pending && this.pendingOffset < this.pending.byteLength) { + throw new Error('Stored video grew after its size was resolved') + } + if (await this.nextNonEmptyChunk(signal)) { + throw new Error('Stored video grew after its size was resolved') + } + } + + async close(): Promise { + await this.iterator.return?.() + } +} + +async function readUploadResponse(response: Response, signal: AbortSignal): Promise { + try { + return await readResponseTextWithLimit(response, { + maxBytes: ERROR_RESPONSE_MAX_BYTES, + label: 'TikTok upload response', + signal, + }) + } catch { + throwIfAborted(signal) + return 'Response body exceeded the allowed size' + } +} + +async function uploadChunk(options: { + uploadUrl: string + chunk: Buffer + chunkIndex: number + totalChunkCount: number + start: number + end: number + totalBytes: number + mimeType: string + requestId: string + signal: AbortSignal +}): Promise { + const expectedStatus = options.chunkIndex === options.totalChunkCount - 1 ? 201 : 206 + + for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt++) { + throwIfAborted(options.signal) + const response = await fetch(options.uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': options.mimeType, + 'Content-Length': String(options.chunk.byteLength), + 'Content-Range': `bytes ${options.start}-${options.end}/${options.totalBytes}`, + }, + body: new Uint8Array(options.chunk), + signal: options.signal, + }) + + if (response.status === expectedStatus) { + await readUploadResponse(response, options.signal) + return + } + + const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) + const errorText = await readUploadResponse(response, options.signal) + const retryable = response.status >= 500 && response.status <= 599 + + if (retryable && attempt < MAX_UPLOAD_ATTEMPTS) { + logger.warn(`[${options.requestId}] Retrying TikTok video chunk`, { + chunkIndex: options.chunkIndex, + attempt, + status: response.status, + }) + await sleepWithAbort(backoffWithJitter(attempt, retryAfterMs), options.signal) + continue + } + + throw new Error( + `TikTok rejected video chunk ${options.chunkIndex + 1}/${options.totalChunkCount}: expected HTTP ${expectedStatus}, received HTTP ${response.status}${errorText ? ` ${errorText}` : ''}` + ) + } +} + +/** + * Streams a stored video into TikTok sequentially. Only the current provider chunk is retained, + * so retry memory is bounded independently of total file size. + */ +export async function streamStoredVideoToTikTok(options: StreamStoredVideoOptions): Promise { + const plan = computeTikTokChunkPlan(options.totalBytes) + throwIfAborted(options.signal) + const stream = await downloadFileStream({ key: options.key, context: options.context }) + const unbindAbort = bindStreamToAbort(stream, options.signal) + const reader = new ExactStreamReader(stream) + + try { + for (let chunkIndex = 0; chunkIndex < plan.totalChunkCount; chunkIndex++) { + const start = chunkIndex * plan.chunkSize + const isLastChunk = chunkIndex === plan.totalChunkCount - 1 + const end = isLastChunk ? options.totalBytes - 1 : start + plan.chunkSize - 1 + const chunk = await reader.readExactly(end - start + 1, options.signal) + if (isLastChunk) await reader.assertExhausted(options.signal) + + await uploadChunk({ + uploadUrl: options.uploadUrl, + chunk, + chunkIndex, + totalChunkCount: plan.totalChunkCount, + start, + end, + totalBytes: options.totalBytes, + mimeType: options.mimeType, + requestId: options.requestId, + signal: options.signal, + }) + } + } finally { + unbindAbort() + stream.destroy() + await reader.close().catch(() => {}) + } +} diff --git a/apps/sim/app/api/webhooks/tiktok/route.test.ts b/apps/sim/app/api/webhooks/tiktok/route.test.ts index 71d07947cab..6ad1f0676eb 100644 --- a/apps/sim/app/api/webhooks/tiktok/route.test.ts +++ b/apps/sim/app/api/webhooks/tiktok/route.test.ts @@ -6,16 +6,13 @@ import crypto from 'node:crypto' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnqueue, mockExecuteTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({ - mockEnqueue: vi.fn(), - mockExecuteTikTokWebhookIngress: vi.fn(), +const { mockEnqueueTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({ + mockEnqueueTikTokWebhookIngress: vi.fn(), mockRelease: vi.fn(), })) vi.mock('@/background/tiktok-webhook-ingress', () => ({ - executeTikTokWebhookIngress: mockExecuteTikTokWebhookIngress, - TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT: 50, - TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS: 3, + enqueueTikTokWebhookIngress: mockEnqueueTikTokWebhookIngress, })) vi.mock('@/lib/core/admission/gate', () => ({ @@ -23,10 +20,6 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: vi.fn(() => ({ release: mockRelease })), })) -vi.mock('@/lib/core/async-jobs', () => ({ - getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })), -})) - vi.mock('@/lib/core/config/env', () => ({ env: { TIKTOK_CLIENT_ID: 'client-key', @@ -73,7 +66,7 @@ function signedRequest(overrides?: { clientKey?: string }): NextRequest { describe('TikTok webhook ingress route', () => { beforeEach(() => { vi.clearAllMocks() - mockEnqueue.mockResolvedValue('ingress-job-1') + mockEnqueueTikTokWebhookIngress.mockResolvedValue('ingress-job-1') }) it('returns 200 only after the verified delivery is accepted by the job queue', async () => { @@ -81,25 +74,20 @@ describe('TikTok webhook ingress route', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) - expect(mockEnqueue).toHaveBeenCalledWith( - 'tiktok-webhook-ingress', + expect(mockEnqueueTikTokWebhookIngress).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ client_key: 'client-key', user_openid: 'act.user', }), requestId: 'request-1', - }), - expect.objectContaining({ - maxAttempts: 3, - runner: expect.any(Function), }) ) expect(mockRelease).toHaveBeenCalledOnce() }) it('returns 503 when durable acceptance fails so TikTok retries', async () => { - mockEnqueue.mockRejectedValue(new Error('queue unavailable')) + mockEnqueueTikTokWebhookIngress.mockRejectedValue(new Error('queue unavailable')) const response = await POST(signedRequest()) @@ -111,6 +99,6 @@ describe('TikTok webhook ingress route', () => { const response = await POST(signedRequest({ clientKey: 'other-client-key' })) expect(response.status).toBe(401) - expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockEnqueueTikTokWebhookIngress).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/webhooks/tiktok/route.ts b/apps/sim/app/api/webhooks/tiktok/route.ts index 5fecb9134d0..b7f79860586 100644 --- a/apps/sim/app/api/webhooks/tiktok/route.ts +++ b/apps/sim/app/api/webhooks/tiktok/route.ts @@ -3,7 +3,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { tiktokWebhookEnvelopeSchema } from '@/lib/api/contracts/webhooks' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' -import { getJobQueue } from '@/lib/core/async-jobs' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -15,9 +14,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok' import { - executeTikTokWebhookIngress, - TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, - TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS, + enqueueTikTokWebhookIngress, type TikTokWebhookIngressPayload, } from '@/background/tiktok-webhook-ingress' @@ -107,15 +104,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requestId, receivedAt, } - const jobQueue = await getJobQueue() - const jobId = await jobQueue.enqueue('tiktok-webhook-ingress', payload, { - maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS, - concurrencyKey: 'tiktok-webhook-ingress', - concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, - runner: async () => { - await executeTikTokWebhookIngress(payload) - }, - }) + const jobId = await enqueueTikTokWebhookIngress(payload) logger.info(`[${requestId}] Accepted TikTok webhook delivery`, { event: envelope.event, diff --git a/apps/sim/background/tiktok-webhook-ingress.test.ts b/apps/sim/background/tiktok-webhook-ingress.test.ts index 7673053bb95..0fad01bf483 100644 --- a/apps/sim/background/tiktok-webhook-ingress.test.ts +++ b/apps/sim/background/tiktok-webhook-ingress.test.ts @@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDispatchResolvedWebhookTarget, mockFindTikTokWebhookTargets } = vi.hoisted(() => ({ - mockDispatchResolvedWebhookTarget: vi.fn(), - mockFindTikTokWebhookTargets: vi.fn(), -})) +const { mockDispatchResolvedWebhookTarget, mockEnqueue, mockFindTikTokWebhookTargetPage } = + vi.hoisted(() => ({ + mockDispatchResolvedWebhookTarget: vi.fn(), + mockEnqueue: vi.fn(), + mockFindTikTokWebhookTargetPage: vi.fn(), + })) vi.mock('@trigger.dev/sdk', () => ({ task: vi.fn((config: unknown) => config), @@ -18,10 +20,14 @@ vi.mock('@/lib/webhooks/processor', () => ({ })) vi.mock('@/lib/webhooks/providers/tiktok-targets', () => ({ - findTikTokWebhookTargets: mockFindTikTokWebhookTargets, + findTikTokWebhookTargetPage: mockFindTikTokWebhookTargetPage, +})) +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })), })) import { + enqueueTikTokWebhookIngress, executeTikTokWebhookIngress, type TikTokWebhookIngressPayload, } from '@/background/tiktok-webhook-ingress' @@ -39,24 +45,27 @@ const payload: TikTokWebhookIngressPayload = { receivedAt: 1_725_000_000_000, } -const targets = [ - { - webhook: { id: 'webhook-1', path: 'tiktok', provider: 'tiktok' }, - workflow: { id: 'workflow-1' }, - }, - { - webhook: { id: 'webhook-2', path: 'tiktok', provider: 'tiktok' }, - workflow: { id: 'workflow-2' }, - }, -] +const firstTarget = { + webhook: { id: 'webhook-1', path: 'tiktok', provider: 'tiktok' }, + workflow: { id: 'workflow-1' }, +} +const secondTarget = { + webhook: { id: 'webhook-2', path: 'tiktok', provider: 'tiktok' }, + workflow: { id: 'workflow-2' }, +} describe('executeTikTokWebhookIngress', () => { beforeEach(() => { vi.clearAllMocks() + mockEnqueue.mockResolvedValue('ingress-job-1') }) it('acknowledges deliveries without active targets', async () => { - mockFindTikTokWebhookTargets.mockResolvedValue([]) + mockFindTikTokWebhookTargetPage.mockResolvedValue({ + hasMore: false, + nextCursor: null, + targets: [], + }) await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({ ignored: 0, @@ -66,44 +75,99 @@ describe('executeTikTokWebhookIngress', () => { expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled() }) - it('dispatches every resolved target and reports typed outcomes', async () => { - mockFindTikTokWebhookTargets.mockResolvedValue(targets) - mockDispatchResolvedWebhookTarget - .mockResolvedValueOnce({ + it('dispatches one bounded page and returns its continuation cursor', async () => { + const events: string[] = [] + mockFindTikTokWebhookTargetPage.mockImplementationOnce(async () => { + events.push('page-1') + return { + hasMore: true, + nextCursor: 'webhook-1', + targets: [firstTarget], + } + }) + mockDispatchResolvedWebhookTarget.mockImplementationOnce(async () => { + events.push('dispatch-1') + return { outcome: 'queued', reason: 'queued', response: new Response(null, { status: 200 }), - }) - .mockResolvedValueOnce({ - outcome: 'ignored', - reason: 'event-mismatch', - response: new Response(null, { status: 200 }), - }) + } + }) await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({ - ignored: 1, + ignored: 0, + nextCursor: 'webhook-1', processed: 1, - targetCount: 2, + targetCount: 1, }) - expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2) + expect(events).toEqual(['page-1', 'dispatch-1']) + expect(mockFindTikTokWebhookTargetPage).toHaveBeenNthCalledWith( + 1, + 'act.user', + 'request-1', + undefined + ) + }) + + it('enqueues the next page only after the current page succeeds', async () => { + mockFindTikTokWebhookTargetPage.mockResolvedValue({ + hasMore: true, + nextCursor: 'webhook-1', + targets: [firstTarget], + }) + mockDispatchResolvedWebhookTarget.mockResolvedValue({ + outcome: 'queued', + reason: 'queued', + response: new Response(null, { status: 200 }), + }) + + await enqueueTikTokWebhookIngress(payload) + const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } + await options.runner() + + expect(mockEnqueue).toHaveBeenNthCalledWith( + 2, + 'tiktok-webhook-ingress', + expect.objectContaining({ afterWebhookId: 'webhook-1' }), + expect.objectContaining({ + jobId: 'tiktok-webhook-ingress:request-1:webhook-1', + }) + ) }) it('throws after a target failure so the durable ingress job retries', async () => { - mockFindTikTokWebhookTargets.mockResolvedValue(targets) + mockFindTikTokWebhookTargetPage.mockResolvedValueOnce({ + hasMore: false, + nextCursor: 'webhook-2', + targets: [firstTarget, secondTarget], + }) mockDispatchResolvedWebhookTarget - .mockResolvedValueOnce({ - outcome: 'queued', - reason: 'queued', - response: new Response(null, { status: 200 }), - }) .mockResolvedValueOnce({ outcome: 'failed', reason: 'queue-failed', response: new Response(null, { status: 500 }), }) + .mockResolvedValueOnce({ + outcome: 'queued', + reason: 'queued', + response: new Response(null, { status: 200 }), + }) await expect(executeTikTokWebhookIngress(payload)).rejects.toThrow( 'Failed to dispatch 1 of 2 TikTok webhook targets' ) + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2) + }) + + it('fails closed if a full target page does not provide a forward cursor', async () => { + mockFindTikTokWebhookTargetPage.mockResolvedValue({ + hasMore: true, + nextCursor: null, + targets: [], + }) + + await expect(executeTikTokWebhookIngress(payload)).rejects.toThrow( + 'TikTok webhook target pagination did not advance' + ) }) }) diff --git a/apps/sim/background/tiktok-webhook-ingress.ts b/apps/sim/background/tiktok-webhook-ingress.ts index d5b4bfb9480..369c88dd7c9 100644 --- a/apps/sim/background/tiktok-webhook-ingress.ts +++ b/apps/sim/background/tiktok-webhook-ingress.ts @@ -2,8 +2,9 @@ import { createLogger } from '@sim/logger' import { task } from '@trigger.dev/sdk' import { NextRequest } from 'next/server' import type { TikTokWebhookEnvelope } from '@/lib/api/contracts/webhooks' +import { getJobQueue } from '@/lib/core/async-jobs' import { dispatchResolvedWebhookTarget } from '@/lib/webhooks/processor' -import { findTikTokWebhookTargets } from '@/lib/webhooks/providers/tiktok-targets' +import { findTikTokWebhookTargetPage } from '@/lib/webhooks/providers/tiktok-targets' const logger = createLogger('TikTokWebhookIngressTask') @@ -11,6 +12,7 @@ export const TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT = 50 export const TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS = 3 export interface TikTokWebhookIngressPayload { + afterWebhookId?: string envelope: TikTokWebhookEnvelope headers: { 'content-type': string @@ -21,38 +23,41 @@ export interface TikTokWebhookIngressPayload { export interface TikTokWebhookIngressResult { ignored: number + nextCursor?: string processed: number targetCount: number } /** - * Resolves and dispatches all active workflow targets for one verified TikTok delivery. Throwing - * after any retryable target failure lets Trigger.dev replay the fanout; workflow-level - * idempotency prevents already-queued targets from executing twice. + * Resolves and dispatches one fixed-size keyset page for a verified TikTok delivery. Each page is a + * separate durable job, so retries replay at most one bounded page and successful pages continue + * from their last webhook ID. */ export async function executeTikTokWebhookIngress( payload: TikTokWebhookIngressPayload ): Promise { - const targets = await findTikTokWebhookTargets(payload.envelope.user_openid, payload.requestId) - if (targets.length === 0) { - logger.info(`[${payload.requestId}] No TikTok webhook targets found`, { - event: payload.envelope.event, - userOpenIdPrefix: payload.envelope.user_openid.slice(0, 12), - }) - return { ignored: 0, processed: 0, targetCount: 0 } - } - const request = new NextRequest('http://internal/api/webhooks/tiktok', { method: 'POST', headers: payload.headers, body: JSON.stringify(payload.envelope), }) + const page = await findTikTokWebhookTargetPage( + payload.envelope.user_openid, + payload.requestId, + payload.afterWebhookId + ) + const nextCursor = page.hasMore ? page.nextCursor : null + if (page.hasMore && (!nextCursor || nextCursor === payload.afterWebhookId)) { + throw new Error('TikTok webhook target pagination did not advance') + } + let ignored = 0 let processed = 0 let failed = 0 + const targetCount = page.targets.length - for (const { webhook, workflow } of targets) { + for (const { webhook, workflow } of page.targets) { const result = await dispatchResolvedWebhookTarget( webhook, workflow, @@ -76,17 +81,55 @@ export async function executeTikTokWebhookIngress( } if (failed > 0) { - throw new Error(`Failed to dispatch ${failed} of ${targets.length} TikTok webhook targets`) + throw new Error(`Failed to dispatch ${failed} of ${targetCount} TikTok webhook targets`) } - logger.info(`[${payload.requestId}] TikTok webhook fanout completed`, { + if (targetCount === 0) { + logger.info(`[${payload.requestId}] No TikTok webhook targets found in page`, { + event: payload.envelope.event, + userOpenIdPrefix: payload.envelope.user_openid.slice(0, 12), + }) + return { + ignored: 0, + processed: 0, + targetCount: 0, + ...(nextCursor ? { nextCursor } : {}), + } + } + + logger.info(`[${payload.requestId}] TikTok webhook fanout page completed`, { event: payload.envelope.event, ignored, + nextCursor, processed, - targetCount: targets.length, + targetCount, }) - return { ignored, processed, targetCount: targets.length } + return { ignored, processed, targetCount, ...(nextCursor ? { nextCursor } : {}) } +} + +async function runTikTokWebhookIngressJob(payload: TikTokWebhookIngressPayload): Promise { + const result = await executeTikTokWebhookIngress(payload) + if (!result.nextCursor) return + + await enqueueTikTokWebhookIngress({ + ...payload, + afterWebhookId: result.nextCursor, + }) +} + +/** Enqueues one bounded TikTok webhook fanout page with stable continuation identity. */ +export async function enqueueTikTokWebhookIngress( + payload: TikTokWebhookIngressPayload +): Promise { + const jobQueue = await getJobQueue() + return jobQueue.enqueue('tiktok-webhook-ingress', payload, { + jobId: `tiktok-webhook-ingress:${payload.requestId}:${payload.afterWebhookId ?? 'root'}`, + maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS, + concurrencyKey: 'tiktok-webhook-ingress', + concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, + runner: async () => runTikTokWebhookIngressJob(payload), + }) } export const tiktokWebhookIngressTask = task({ @@ -101,5 +144,5 @@ export const tiktokWebhookIngressTask = task({ queue: { concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, }, - run: async (payload: TikTokWebhookIngressPayload) => executeTikTokWebhookIngress(payload), + run: async (payload: TikTokWebhookIngressPayload) => runTikTokWebhookIngressJob(payload), }) diff --git a/apps/sim/blocks/blocks/instagram.test.ts b/apps/sim/blocks/blocks/instagram.test.ts new file mode 100644 index 00000000000..186195735c3 --- /dev/null +++ b/apps/sim/blocks/blocks/instagram.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { InstagramBlock } from '@/blocks/blocks/instagram' + +describe('InstagramBlock', () => { + const buildParams = InstagramBlock.tools.config.params! + const selectTool = InstagramBlock.tools.config.tool! + + it('stays hidden from discovery until the Instagram integration is approved', () => { + expect(InstagramBlock.hideFromToolbar).toBe(true) + }) + + it('clears stale operation parameters from the runtime input merge', () => { + const inputs = { + operation: 'instagram_get_conversation_messages', + oauthCredential: 'credential-1', + conversationId: 'conversation-1', + limit: '12', + after: 'message-cursor', + mediaId: 'stale-media-id', + filename: 'stale-filename.jpg', + caption: 'stale caption', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs).toMatchObject({ + credential: 'credential-1', + conversationId: 'conversation-1', + limit: 12, + after: 'message-cursor', + }) + expect(finalInputs.mediaId).toBeUndefined() + expect(finalInputs.filename).toBeUndefined() + expect(finalInputs.caption).toBeUndefined() + }) + + it('rejects operations outside the registered Instagram tool set', () => { + expect(() => selectTool({ operation: 'instagram_unknown_operation' })).toThrow( + 'Unsupported Instagram operation' + ) + }) +}) diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index 54e3ffce4f9..e9bdd4c95aa 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -29,7 +29,7 @@ function resolveCarouselMediaInput(value: unknown): object[] | string | undefine const files = normalizeFileInput(parsed) if (files) return files } catch { - // plain URL or comma-separated URLs + return trimmed } return trimmed } @@ -53,20 +53,69 @@ const IG_USER_ID_OPS = [ 'instagram_get_account_insights', ] +const OPERATION_PARAM_KEYS: Record = { + instagram_get_profile: [], + instagram_list_media: ['igUserId', 'limit', 'after'], + instagram_get_media: ['mediaId'], + instagram_download_media: ['mediaId', 'filename'], + instagram_list_stories: ['igUserId', 'limit', 'after'], + instagram_publish_image: ['igUserId', 'caption', 'altText', 'isAiGenerated'], + instagram_publish_video: ['igUserId', 'caption'], + instagram_publish_reel: ['igUserId', 'caption', 'shareToFeed', 'thumbOffset'], + instagram_publish_story: ['igUserId'], + instagram_publish_carousel: ['igUserId', 'caption'], + instagram_get_container_status: ['containerId'], + instagram_get_publishing_limit: ['igUserId'], + instagram_list_comments: ['mediaId', 'limit', 'after'], + instagram_reply_to_comment: ['commentId', 'message'], + instagram_hide_comment: ['commentId', 'hide'], + instagram_delete_comment: ['commentId'], + instagram_set_comments_enabled: ['mediaId', 'commentEnabled'], + instagram_private_reply: ['igUserId', 'commentId', 'message'], + instagram_list_conversations: ['igUserId', 'limit', 'after'], + instagram_get_conversation_messages: ['conversationId', 'limit', 'after'], + instagram_get_message: ['messageId'], + instagram_send_text_message: ['igUserId', 'recipientId', 'message'], + instagram_get_account_insights: [ + 'igUserId', + 'metrics', + 'period', + 'since', + 'until', + 'metricType', + 'breakdown', + 'timeframe', + ], + instagram_get_media_insights: ['mediaId', 'metrics'], +} + +const INSTAGRAM_TOOL_IDS = new Set(Object.keys(OPERATION_PARAM_KEYS)) +const INSTAGRAM_OPERATION_INPUT_KEYS = new Set([ + 'image', + 'video', + 'cover', + 'media', + 'carouselMedia', + ...Object.values(OPERATION_PARAM_KEYS).flat(), +]) + +const NUMERIC_PARAM_KEYS = new Set(['limit', 'thumbOffset']) +const BOOLEAN_PARAM_KEYS = new Set(['hide', 'commentEnabled', 'shareToFeed', 'isAiGenerated']) + export const InstagramBlock: BlockConfig = { type: 'instagram', name: 'Instagram', - description: 'Publish content, moderate comments, and manage Instagram DMs', + description: 'Publish and download 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.', + 'Integrate Instagram into workflows. Publish and download images, videos, Reels, stories, and carousels as canonical User Files; moderate comments; send DMs; and pull account or media insights.', docsLink: 'https://docs.sim.ai/integrations/instagram', category: 'tools', integrationType: IntegrationType.Marketing, - // Gradient fills the tile; white camera glyph sits on top (same pattern as AWS icons). bgColor: 'radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)', iconColor: '#E4405F', icon: InstagramIcon, + hideFromToolbar: true, subBlocks: [ { id: 'operation', @@ -76,6 +125,7 @@ export const InstagramBlock: BlockConfig = { { label: 'Get Profile', id: 'instagram_get_profile' }, { label: 'List Media', id: 'instagram_list_media' }, { label: 'Get Media', id: 'instagram_get_media' }, + { label: 'Download Media', id: 'instagram_download_media' }, { label: 'List Stories', id: 'instagram_list_stories' }, { label: 'Publish Image', id: 'instagram_publish_image' }, { label: 'Publish Video', id: 'instagram_publish_video' }, @@ -121,7 +171,6 @@ export const InstagramBlock: BlockConfig = { required: true, }, - // Publish Image — JPEG upload / file ref (same basic+advanced pattern as Gmail) { id: 'imageUpload', title: 'Image', @@ -147,7 +196,6 @@ export const InstagramBlock: BlockConfig = { required: { field: 'operation', value: 'instagram_publish_image' }, }, - // Publish Video / Reel — video + optional cover { id: 'videoUpload', title: 'Video', @@ -215,7 +263,6 @@ export const InstagramBlock: BlockConfig = { required: false, }, - // Publish Story — single image or video { id: 'storyMediaUpload', title: 'Media', @@ -241,7 +288,6 @@ export const InstagramBlock: BlockConfig = { required: { field: 'operation', value: 'instagram_publish_story' }, }, - // Publish Carousel — up to 10 items { id: 'carouselMediaUpload', title: 'Media', @@ -319,7 +365,6 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, title: 'Share to Feed', type: 'dropdown', options: [ - // Yes first: Meta shares reels to the feed by default when the param is omitted. { label: 'Yes', id: 'true' }, { label: 'No', id: 'false' }, ], @@ -345,6 +390,7 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, field: 'operation', value: [ 'instagram_get_media', + 'instagram_download_media', 'instagram_list_comments', 'instagram_set_comments_enabled', 'instagram_get_media_insights', @@ -354,12 +400,21 @@ Return ONLY the comma-separated URLs - no explanations, no extra text.`, field: 'operation', value: [ 'instagram_get_media', + 'instagram_download_media', 'instagram_list_comments', 'instagram_set_comments_enabled', 'instagram_get_media_insights', ], }, }, + { + id: 'filename', + title: 'Filename', + type: 'short-input', + placeholder: 'Optional filename override', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_download_media' }, + }, { id: 'containerId', title: 'Container ID', @@ -494,6 +549,7 @@ Return ONLY the comma-separated metric names - no explanations, no extra text.`, { label: 'Day', id: 'day' }, { label: 'Week', id: 'week' }, { label: '28 Days', id: 'days_28' }, + { label: 'Month', id: 'month' }, { label: 'Lifetime', id: 'lifetime' }, { label: 'Total Over Range', id: 'total_over_range' }, ], @@ -565,8 +621,6 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, 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' }, @@ -584,7 +638,13 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: ['instagram_list_media', 'instagram_list_comments', 'instagram_list_conversations'], + value: [ + 'instagram_list_media', + 'instagram_list_stories', + 'instagram_list_comments', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + ], }, }, { @@ -595,7 +655,13 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, mode: 'advanced', condition: { field: 'operation', - value: ['instagram_list_media', 'instagram_list_comments', 'instagram_list_conversations'], + value: [ + 'instagram_list_media', + 'instagram_list_stories', + 'instagram_list_comments', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + ], }, }, { @@ -612,6 +678,7 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, 'instagram_get_profile', 'instagram_list_media', 'instagram_get_media', + 'instagram_download_media', 'instagram_list_stories', 'instagram_publish_image', 'instagram_publish_video', @@ -634,62 +701,50 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, 'instagram_get_media_insights', ], config: { - tool: (params) => params.operation, + tool: (params) => { + const operation = + typeof params.operation === 'string' ? params.operation : 'instagram_get_profile' + if (!INSTAGRAM_TOOL_IDS.has(operation)) { + throw new Error(`Unsupported Instagram operation: ${operation}`) + } + return operation + }, params: (params) => { - const { oauthCredential, image, video, cover, media, carouselMedia, ...rest } = params + const operation = typeof params.operation === 'string' ? params.operation : '' const result: Record = { - credential: oauthCredential, + credential: params.oauthCredential, } - const operation = params.operation as string | undefined + for (const key of INSTAGRAM_OPERATION_INPUT_KEYS) { + result[key] = undefined + } if (operation === 'instagram_publish_image') { - const resolved = resolveSingleMediaInput(image) + const resolved = resolveSingleMediaInput(params.image) if (resolved) result.image = resolved } else if ( operation === 'instagram_publish_video' || operation === 'instagram_publish_reel' ) { - const resolvedVideo = resolveSingleMediaInput(video) + const resolvedVideo = resolveSingleMediaInput(params.video) if (resolvedVideo) result.video = resolvedVideo - const resolvedCover = resolveSingleMediaInput(cover) + const resolvedCover = resolveSingleMediaInput(params.cover) if (resolvedCover) result.cover = resolvedCover } else if (operation === 'instagram_publish_story') { - const resolved = resolveSingleMediaInput(media) + const resolved = resolveSingleMediaInput(params.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) + const resolved = resolveCarouselMediaInput(params.carouselMedia) if (resolved) result.media = resolved } - for (const [key, value] of Object.entries(rest)) { + for (const key of OPERATION_PARAM_KEYS[operation] ?? []) { + const value = params[key] 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') { + if (NUMERIC_PARAM_KEYS.has(key)) { result[key] = Number(value) - } else if ( - key === 'hide' || - key === 'commentEnabled' || - key === 'shareToFeed' || - key === 'isAiGenerated' - ) { + } else if (BOOLEAN_PARAM_KEYS.has(key)) { result[key] = value === true || value === 'true' } else { result[key] = value @@ -730,6 +785,7 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, description: 'Video frame offset in milliseconds for the Reel cover thumbnail', }, mediaId: { type: 'string', description: 'Instagram media ID' }, + filename: { type: 'string', description: 'Optional filename override for Download Media' }, containerId: { type: 'string', description: 'Media container ID' }, commentId: { type: 'string', description: 'Comment ID' }, message: { type: 'string', description: 'Message or reply text' }, @@ -753,73 +809,282 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, 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 + userId: { + type: 'string', + description: 'Instagram professional account user_id', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + id: { + type: 'string', + description: 'Graph object, comment, or message ID', + condition: { + field: 'operation', + value: [ + 'instagram_get_profile', + 'instagram_get_media', + 'instagram_reply_to_comment', + 'instagram_get_message', + ], + }, + }, + username: { + type: 'string', + description: 'Instagram username', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + name: { + type: 'string', + description: 'Display name', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + accountType: { + type: 'string', + description: 'Business or Media_Creator', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + profilePictureUrl: { + type: 'string', + description: 'Profile picture URL', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + followersCount: { + type: 'number', + description: 'Follower count', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + followsCount: { + type: 'number', + description: 'Following count', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + mediaCount: { + type: 'number', + description: 'Media count', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, 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' }, + type: 'array', + description: 'Media objects from this page', + condition: { field: 'operation', value: 'instagram_list_media' }, + }, + files: { + type: 'file[]', + description: 'Downloaded media as canonical User Files, ordered by carousel position', + condition: { field: 'operation', value: 'instagram_download_media' }, + }, + downloadedCount: { + type: 'number', + description: 'Number of media files downloaded', + condition: { field: 'operation', value: 'instagram_download_media' }, + }, + caption: { + type: 'string', + description: 'Media caption text', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + mediaType: { + type: 'string', + description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM', + condition: { + field: 'operation', + value: ['instagram_get_media', 'instagram_download_media'], + }, + }, + mediaProductType: { + type: 'string', + description: 'Feed, Reels, or Stories product type', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + mediaUrl: { + type: 'string', + description: 'Temporary Instagram CDN URL', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + permalink: { + type: 'string', + description: 'Permalink to the post', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + timestamp: { + type: 'string', + description: 'ISO timestamp', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + likeCount: { + type: 'number', + description: 'Like count', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + commentsCount: { + type: 'number', + description: 'Comments count', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + children: { + type: 'array', + description: 'Carousel child media IDs', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, stories: { - type: 'json', - description: 'Active stories (id, mediaType, mediaUrl, timestamp)', + type: 'array', + description: 'Active stories from this page', + condition: { field: 'operation', value: 'instagram_list_stories' }, + }, + containerId: { + type: 'string', + description: 'Media container ID', + condition: { + field: 'operation', + value: [ + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_container_status', + ], + }, + }, + mediaId: { + type: 'string', + description: 'Published or downloaded media ID', + condition: { + field: 'operation', + value: [ + 'instagram_download_media', + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + ], + }, }, - // 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)', + condition: { + field: 'operation', + value: [ + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_container_status', + ], + }, }, - 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: { + status: { + type: 'string', + description: 'Detailed container status message', + condition: { field: 'operation', value: 'instagram_get_container_status' }, + }, + quotaUsage: { + type: 'number', + description: 'Publishes used in the current window', + condition: { field: 'operation', value: 'instagram_get_publishing_limit' }, + }, + config: { type: 'json', - description: 'Comments (id, text, username, timestamp, likeCount, hidden)', + description: 'Publishing quota config', + condition: { field: 'operation', value: 'instagram_get_publishing_limit' }, + }, + comments: { + type: 'array', + description: 'Comments from this page', + condition: { field: 'operation', value: 'instagram_list_comments' }, + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded', + condition: { + field: 'operation', + value: [ + 'instagram_hide_comment', + 'instagram_delete_comment', + 'instagram_set_comments_enabled', + ], + }, + }, + conversations: { + type: 'array', + description: 'Instagram Direct conversations from this page', + condition: { field: 'operation', value: 'instagram_list_conversations' }, + }, + conversationId: { + type: 'string', + description: 'Conversation ID', + condition: { field: 'operation', value: 'instagram_get_conversation_messages' }, }, - success: { type: 'boolean', description: 'Whether the operation succeeded' }, - // Messaging - conversations: { type: 'json', description: 'Conversations (id, updatedTime)' }, - conversationId: { type: 'string', description: 'Conversation id' }, messages: { - type: 'json', - description: - 'Message references (id, createdTime); use Get Message for sender, recipient, and text', - }, - createdTime: { type: 'string', description: 'Message created timestamp' }, - fromId: { type: 'string', description: 'Sender Instagram-scoped id' }, - fromUsername: { type: 'string', description: 'Sender username' }, - toId: { type: 'string', description: 'Recipient id' }, - message: { type: 'string', description: 'Message text' }, - messageId: { type: 'string', description: 'Sent or retrieved message id' }, - recipientId: { type: 'string', description: 'DM recipient id' }, - // Insights & pagination + type: 'array', + description: 'Message references; use Get Message for sender, recipient, and text', + condition: { field: 'operation', value: 'instagram_get_conversation_messages' }, + }, + createdTime: { + type: 'string', + description: 'Message created timestamp', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + fromId: { + type: 'string', + description: 'Sender Instagram-scoped ID', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + fromUsername: { + type: 'string', + description: 'Sender username', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + toId: { + type: 'string', + description: 'Recipient ID', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + message: { + type: 'string', + description: 'Message text', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + messageId: { + type: 'string', + description: 'Sent message ID', + condition: { + field: 'operation', + value: ['instagram_private_reply', 'instagram_send_text_message'], + }, + }, + recipientId: { + type: 'string', + description: 'DM recipient ID', + condition: { + field: 'operation', + value: ['instagram_private_reply', 'instagram_send_text_message'], + }, + }, insights: { - type: 'json', - description: 'Insight metrics (name, period, title, description, values, totalValue)', + type: 'array', + description: 'Insight metrics', + condition: { + field: 'operation', + value: ['instagram_get_account_insights', 'instagram_get_media_insights'], + }, + }, + nextCursor: { + type: 'string', + description: 'Pagination cursor for the next page', + condition: { + field: 'operation', + value: [ + 'instagram_list_media', + 'instagram_list_stories', + 'instagram_list_comments', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + ], + }, }, - nextCursor: { type: 'string', description: 'Pagination cursor for the next page' }, - error: { type: 'string', description: 'Error message if the operation failed' }, }, } @@ -870,7 +1135,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. 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.', + '# 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, and final container status.', }, { name: 'moderate-instagram-comments', @@ -883,7 +1148,7 @@ export const InstagramBlockMeta = { 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.', + '# 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 message references.\n2. Optionally Get Message for one of the 20 most recent message IDs when you need full details.\n3. Send Text Message to the recipient ID with a clear, helpful reply.\n\n## Output\nThe sent message ID and recipient ID.', }, { name: 'fetch-instagram-insights', @@ -892,5 +1157,11 @@ export const InstagramBlockMeta = { content: '# Fetch Instagram Insights\n\nRetrieve performance metrics for the account or a specific post.\n\n## Steps\n1. For account interaction trends, run Get Account Insights with comma-separated metrics and the day period. Use lifetime plus a timeframe for demographic metrics.\n2. For a specific post, run Get Media Insights with the media ID and metrics like views, reach, likes, comments, saved, or shares.\n3. Summarize the returned insight values for the reporting window.\n\n## Output\nThe insights JSON plus a short plain-language summary of the key metrics.', }, + { + name: 'download-instagram-media', + description: 'Download an Instagram post or story as canonical User Files.', + content: + '# Download Instagram Media\n\nMaterialize Instagram media into durable User Files for downstream blocks.\n\n## Steps\n1. Use List Media, Get Media, or List Stories to find the media ID.\n2. Run Download Media with that ID. Carousel children are downloaded in display order.\n3. Pass the files output directly to a file[] input such as Gmail attachments.\n\n## Output\nA canonical User File array plus the source media ID, media type, and downloaded count.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/tiktok.test.ts b/apps/sim/blocks/blocks/tiktok.test.ts new file mode 100644 index 00000000000..a9aec3ca9eb --- /dev/null +++ b/apps/sim/blocks/blocks/tiktok.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { TikTokBlock } from '@/blocks/blocks/tiktok' + +describe('TikTokBlock', () => { + const buildParams = TikTokBlock.tools.config.params! + const selectTool = TikTokBlock.tools.config.tool! + + it('keeps direct posting unavailable until one-use human approval is supported', () => { + expect(TikTokBlock.tools.access).not.toContain('tiktok_direct_post_video') + expect(() => selectTool({ operation: 'tiktok_direct_post_video' })).toThrow( + 'Unsupported TikTok operation' + ) + }) + + it('uses one canonical file parameter for upload and reference modes', () => { + const fileInputs = TikTokBlock.subBlocks.filter( + (subBlock) => subBlock.id === 'videoFile' || subBlock.id === 'videoFileRef' + ) + + expect(fileInputs).toHaveLength(2) + expect(fileInputs.every((subBlock) => subBlock.canonicalParamId === 'file')).toBe(true) + }) + + it('forwards only draft parameters and preserves a canonical UserFile', () => { + const file = { + id: 'file-1', + key: 'workspace/workspace-1/file-1', + name: 'video.mp4', + size: 1024, + type: 'video/mp4', + url: '/api/files/serve?key=workspace%2Fworkspace-1%2Ffile-1', + } + + const inputs = { + operation: 'tiktok_upload_video_draft', + file, + privacyLevel: 'PUBLIC_TO_EVERYONE', + musicUsageConsent: 'accepted', + videoIds: 'stale-video-id', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.file).toEqual(file) + expect(finalInputs.privacyLevel).toBeUndefined() + expect(finalInputs.musicUsageConsent).toBeUndefined() + expect(finalInputs.videoIds).toBeUndefined() + }) + + it('declares file-like outputs with canonical block output types', () => { + expect(TikTokBlock.outputs.avatarFile.type).toBe('file') + expect(TikTokBlock.outputs.creatorAvatarFile.type).toBe('file') + expect(TikTokBlock.outputs.videos.type).toBe('array') + expect(TikTokBlock.outputs.publiclyAvailablePostId.type).toBe('array') + }) +}) diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index dcd2dabcb69..0cddbde45d6 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -6,15 +6,41 @@ import { normalizeFileInput } from '@/blocks/utils' import type { TikTokResponse } from '@/tools/tiktok/types' import { getTrigger } from '@/triggers' -const VIDEO_POST_OPERATIONS = ['tiktok_direct_post_video', 'tiktok_upload_video_draft'] +const VIDEO_UPLOAD_OPERATIONS = ['tiktok_upload_video_draft'] +const TIKTOK_TOOL_IDS = new Set([ + 'tiktok_get_user', + 'tiktok_list_videos', + 'tiktok_query_videos', + 'tiktok_query_creator_info', + 'tiktok_upload_video_draft', + 'tiktok_get_post_status', +]) +const TIKTOK_OPERATION_INPUT_KEYS = [ + 'fields', + 'maxCount', + 'cursor', + 'videoIds', + 'file', + 'publishId', + 'title', + 'privacyLevel', + 'disableDuet', + 'disableStitch', + 'disableComment', + 'videoCoverTimestampMs', + 'isAigc', + 'brandContentToggle', + 'brandOrganicToggle', + 'musicUsageConsent', +] as const export const TikTokBlock: BlockConfig = { type: 'tiktok', name: 'TikTok', - description: 'Access TikTok profiles and videos, and publish content', + description: 'Access TikTok profiles and videos, and upload inbox drafts', authMode: AuthMode.OAuth, longDescription: - 'Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos directly to TikTok (or send them to the inbox as drafts) from an uploaded file or a file produced earlier in the workflow, then track post status.', + 'Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Send uploaded videos to the TikTok inbox as drafts for human review and publishing, then track post status.', docsLink: 'https://docs.sim.ai/integrations/tiktok', category: 'tools', integrationType: IntegrationType.Communication, @@ -33,14 +59,12 @@ export const TikTokBlock: BlockConfig = { { label: 'List Videos', id: 'tiktok_list_videos' }, { label: 'Query Videos', id: 'tiktok_query_videos' }, { label: 'Query Creator Info', id: 'tiktok_query_creator_info' }, - { label: 'Direct Post Video', id: 'tiktok_direct_post_video' }, { label: 'Upload Video Draft', id: 'tiktok_upload_video_draft' }, { label: 'Get Post Status', id: 'tiktok_get_post_status' }, ], value: () => 'tiktok_get_user', }, - // --- OAuth Credential --- { id: 'credential', title: 'TikTok Account', @@ -62,7 +86,6 @@ export const TikTokBlock: BlockConfig = { required: true, }, - // --- Get User Info --- { id: 'fields', title: 'Fields', @@ -79,7 +102,6 @@ export const TikTokBlock: BlockConfig = { }, }, - // --- List Videos --- { id: 'maxCount', title: 'Max Count', @@ -98,7 +120,6 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_list_videos' }, }, - // --- Query Videos --- { id: 'videoIds', title: 'Video IDs', @@ -108,7 +129,6 @@ export const TikTokBlock: BlockConfig = { required: { field: 'operation', value: 'tiktok_query_videos' }, }, - // --- Video file (Direct Post Video / Upload Video Draft) — Gmail-style upload ⇄ block ref --- { id: 'videoFile', title: 'Video File', @@ -120,8 +140,8 @@ export const TikTokBlock: BlockConfig = { multiple: false, maxSize: 250, description: 'MP4, MOV, or WebM video up to 250 MB.', - condition: { field: 'operation', value: VIDEO_POST_OPERATIONS }, - required: { field: 'operation', value: VIDEO_POST_OPERATIONS }, + condition: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, + required: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, }, { id: 'videoFileRef', @@ -130,134 +150,10 @@ export const TikTokBlock: BlockConfig = { canonicalParamId: 'file', mode: 'advanced', placeholder: 'Reference a video from a previous block', - condition: { field: 'operation', value: VIDEO_POST_OPERATIONS }, - required: { field: 'operation', value: VIDEO_POST_OPERATIONS }, + condition: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, + required: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, }, - // --- Caption (Direct Post Video) --- - { - id: 'title', - title: 'Title / Caption', - type: 'long-input', - placeholder: 'Caption with #hashtags and @mentions', - description: 'Video caption. Maximum 2200 characters.', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - - // --- Privacy & interaction settings (Direct Post Video) --- - { - id: 'privacyLevel', - title: 'Privacy Level', - type: 'dropdown', - options: [ - { label: 'Public', id: 'PUBLIC_TO_EVERYONE' }, - { label: 'Friends', id: 'MUTUAL_FOLLOW_FRIENDS' }, - { label: 'Followers', id: 'FOLLOWER_OF_CREATOR' }, - { label: 'Only Me', id: 'SELF_ONLY' }, - ], - description: - 'Choose manually from the privacyLevelOptions returned by Query Creator Info. TikTok prohibits preselecting a privacy level. Unaudited apps are restricted to Only Me.', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'disableComment', - title: 'Disable Comments', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'disableDuet', - title: 'Disable Duet', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'disableStitch', - title: 'Disable Stitch', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'videoCoverTimestampMs', - title: 'Cover Timestamp (ms)', - type: 'short-input', - placeholder: '1000', - description: 'Timestamp in milliseconds to use as the video cover image.', - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'isAigc', - title: 'AI-Generated Content', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - value: () => 'false', - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'brandContentToggle', - title: 'Paid Partnership', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - value: () => 'false', - description: - 'Disclose this post as a paid partnership promoting a third-party business. Branded content cannot be posted with Only Me privacy.', - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'brandOrganicToggle', - title: 'Promotes Own Business', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - value: () => 'false', - description: "Disclose this post as promoting the creator's own business.", - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'musicUsageConsent', - title: 'TikTok Music Usage Confirmation', - type: 'dropdown', - options: [ - { - label: "I agree to TikTok's Music Usage Confirmation", - id: 'accepted', - }, - ], - description: - "By posting, you agree to TikTok's Music Usage Confirmation. TikTok requires explicit consent before content is uploaded.", - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - - // --- Get Post Status --- { id: 'publishId', title: 'Publish ID', @@ -297,69 +193,52 @@ export const TikTokBlock: BlockConfig = { 'tiktok_list_videos', 'tiktok_query_videos', 'tiktok_query_creator_info', - 'tiktok_direct_post_video', 'tiktok_upload_video_draft', 'tiktok_get_post_status', ], config: { - tool: (params) => params.operation || 'tiktok_get_user', + tool: (params) => { + const operation = params.operation || 'tiktok_get_user' + if (!TIKTOK_TOOL_IDS.has(operation)) { + throw new Error(`Unsupported TikTok operation: ${operation}`) + } + return operation + }, params: (params) => { const operation = params.operation || 'tiktok_get_user' - const toBoolean = (value: unknown): boolean | undefined => - value === undefined || value === '' ? undefined : String(value).toLowerCase() === 'true' + const result: Record = {} + for (const key of TIKTOK_OPERATION_INPUT_KEYS) { + result[key] = undefined + } switch (operation) { case 'tiktok_get_user': - return { - ...(params.fields && { fields: params.fields }), - } + if (params.fields) result.fields = params.fields + break case 'tiktok_list_videos': - return { - ...(params.maxCount && { maxCount: Number(params.maxCount) }), - ...(params.cursor !== undefined && - params.cursor !== '' && { cursor: Number(params.cursor) }), + if (params.maxCount) result.maxCount = Number(params.maxCount) + if (params.cursor !== undefined && params.cursor !== '') { + result.cursor = Number(params.cursor) } + break case 'tiktok_query_videos': - return { - videoIds: (params.videoIds || '') - .split(/[,\n]+/) - .map((id: string) => id.trim()) - .filter(Boolean), - } + result.videoIds = (params.videoIds || '') + .split(/[,\n]+/) + .map((id: string) => id.trim()) + .filter(Boolean) + break case 'tiktok_query_creator_info': - return {} - case 'tiktok_direct_post_video': { - const file = normalizeFileInput(params.file, { single: true }) - return { - file, - title: params.title, - privacyLevel: params.privacyLevel, - disableDuet: toBoolean(params.disableDuet), - disableStitch: toBoolean(params.disableStitch), - disableComment: toBoolean(params.disableComment), - ...(params.videoCoverTimestampMs !== undefined && - params.videoCoverTimestampMs !== '' && { - videoCoverTimestampMs: Number(params.videoCoverTimestampMs), - }), - isAigc: toBoolean(params.isAigc), - brandContentToggle: toBoolean(params.brandContentToggle), - brandOrganicToggle: toBoolean(params.brandOrganicToggle), - musicUsageConsent: params.musicUsageConsent, - } - } + break case 'tiktok_upload_video_draft': { - const file = normalizeFileInput(params.file, { single: true }) - return { - file, - } + result.file = normalizeFileInput(params.file, { single: true }) + break } case 'tiktok_get_post_status': - return { - publishId: params.publishId, - } - default: - return {} + result.publishId = params.publishId + break } + + return result }, }, }, @@ -378,30 +257,10 @@ export const TikTokBlock: BlockConfig = { type: 'json', description: 'Video file to upload (uploaded file or reference from a previous block)', }, - title: { type: 'string', description: 'Video caption or title' }, - privacyLevel: { type: 'string', description: 'Privacy level for the post' }, - disableComment: { type: 'string', description: 'Whether to disable comments' }, - disableDuet: { type: 'string', description: 'Whether to disable duet' }, - disableStitch: { type: 'string', description: 'Whether to disable stitch' }, - videoCoverTimestampMs: { type: 'number', description: 'Video cover timestamp in ms' }, - isAigc: { type: 'string', description: 'Whether the video is AI-generated content' }, - brandContentToggle: { - type: 'string', - description: 'Whether the post is a paid partnership promoting a third-party business', - }, - brandOrganicToggle: { - type: 'string', - description: "Whether the post promotes the creator's own business", - }, - musicUsageConsent: { - type: 'string', - description: "Explicit acceptance of TikTok's Music Usage Confirmation", - }, publishId: { type: 'string', description: 'Publish ID to check status for' }, }, outputs: { - // Get User Info openId: { type: 'string', description: 'Unique TikTok user ID for this application', @@ -464,11 +323,10 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_get_user' }, }, - // List/Query Videos videos: { - type: 'json', + type: 'array', description: - 'Array of video objects (id, title, coverImageUrl, embedLink, embedHtml, duration, createTime, shareUrl, videoDescription, width, height, viewCount, likeCount, commentCount, shareCount)', + 'Video objects with metadata and provider cover URLs. Cover URLs expire after six hours and are not original-video downloads.', condition: { field: 'operation', value: ['tiktok_list_videos', 'tiktok_query_videos'] }, }, cursor: { @@ -482,10 +340,15 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_list_videos' }, }, - // Query Creator Info creatorAvatarUrl: { type: 'string', - description: 'URL of the creator avatar', + description: 'Provider URL of the creator avatar; expires after two hours', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + creatorAvatarFile: { + type: 'file', + description: + 'Canonical workflow file containing the creator avatar, suitable for downstream attachments', condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, creatorUsername: { @@ -499,7 +362,7 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, privacyLevelOptions: { - type: 'json', + type: 'array', description: 'Available privacy levels for posting (array of strings)', condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, @@ -524,17 +387,15 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, - // Direct Post / Upload Draft publishId: { type: 'string', description: 'Publish ID for tracking post status', condition: { field: 'operation', - value: VIDEO_POST_OPERATIONS, + value: VIDEO_UPLOAD_OPERATIONS, }, }, - // Get Post Status status: { type: 'string', description: @@ -547,7 +408,7 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_get_post_status' }, }, publiclyAvailablePostId: { - type: 'json', + type: 'array', description: 'Array of public post IDs once the content is published', condition: { field: 'operation', value: 'tiktok_get_post_status' }, }, @@ -568,15 +429,6 @@ export const TikTokBlockMeta = { tags: ['marketing', 'content-management'], url: 'https://www.tiktok.com', templates: [ - { - icon: TikTokIcon, - title: 'User-reviewed TikTok publisher', - prompt: - 'Build a workflow that prepares a generated video and editable AI-written caption, asks the user to review the video, choose TikTok privacy and interaction settings, and explicitly accept the Music Usage Confirmation before publishing, then checks post status until it completes.', - modules: ['agent', 'workflows'], - category: 'marketing', - tags: ['marketing', 'automation'], - }, { icon: TikTokIcon, title: 'TikTok content calendar drafts', @@ -608,13 +460,6 @@ export const TikTokBlockMeta = { }, ], skills: [ - { - name: 'publish-video-to-tiktok', - description: - 'Guide a user-reviewed direct post to TikTok from an uploaded file or a previous block.', - content: - '# Publish a Video to TikTok\n\nGuide a user-reviewed post to a connected TikTok account.\n\n## Steps\n1. Run Query Creator Info immediately before posting to confirm the account, posting permissions, allowed privacy levels, interaction restrictions, and maximum duration.\n2. Show the user the video and an editable Title/Caption; never publish an unattended or unreviewed file.\n3. Have the user manually choose Privacy Level and each interaction setting from the currently allowed options.\n4. Require the user to accept the TikTok Music Usage Confirmation, then use Direct Post Video with the reviewed settings.\n5. Use Get Post Status with the returned Publish ID until the post completes or fails.\n\n## Output\nReturn the Publish ID and final status (PUBLISH_COMPLETE or FAILED with a reason).', - }, { name: 'send-video-draft-to-inbox', description: "Send a video to the user's TikTok inbox for manual review before posting.", @@ -623,9 +468,9 @@ export const TikTokBlockMeta = { }, { name: 'check-tiktok-post-status', - description: 'Poll the status of a TikTok post or draft until it completes or fails.', + description: 'Poll the status of a TikTok inbox draft until it completes or fails.', content: - '# Check TikTok Post Status\n\nTrack the outcome of a post or draft submitted with any TikTok publish operation.\n\n## Steps\n1. Capture the Publish ID returned by Direct Post Video or Upload Video Draft.\n2. Call Get Post Status with that Publish ID.\n3. Branch on the returned status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD means still in progress, SEND_TO_USER_INBOX means a draft is waiting on the user, PUBLISH_COMPLETE means it succeeded, and FAILED means it did not (read failReason for why).\n4. Repeat on a delay for in-progress statuses until a terminal state is reached.\n\n## Output\nReturn the final status, failReason (if any), and the publiclyAvailablePostId once published.', + '# Check TikTok Draft Status\n\nTrack the outcome of a video submitted to the TikTok inbox for human review.\n\n## Steps\n1. Capture the Publish ID returned by Upload Video Draft.\n2. Call Get Post Status with that Publish ID.\n3. Branch on the returned status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD means still in progress, SEND_TO_USER_INBOX means the draft is waiting on the user, PUBLISH_COMPLETE means the user published it, and FAILED means it did not complete (read failReason for why).\n4. Repeat on a delay for in-progress statuses until a terminal state is reached.\n\n## Output\nReturn the final status, failReason (if any), and the publiclyAvailablePostId once published.', }, { name: 'summarize-tiktok-video-performance', diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index d7afbda4c29..e7acb6322a2 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1467,7 +1467,7 @@ export function LinkedInIcon(props: SVGProps) { ) } -/** Placeholder Instagram glyph — replace with brand SVG from press kit when provided. */ +/** Instagram camera glyph rendered with the block's brand gradient background. */ export function InstagramIcon(props: SVGProps) { return ( ({ + mockDownloadFileFromUrl: vi.fn(), + mockUploadExecutionFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromUrl: mockDownloadFileFromUrl, +})) + +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mockUploadExecutionFile, + uploadFileFromRawData: vi.fn(), +})) + +import { FileToolProcessor } from '@/executor/utils/file-tool-processor' + +const executionContext = { + executionId: 'execution-1', + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', +} as ExecutionContext + +const toolConfig = { + id: 'test_file_output', + name: 'Test File Output', + description: 'Test file output', + version: '1.0.0', + params: {}, + request: { + url: () => 'https://example.com', + method: 'GET', + }, + outputs: { + file: { type: 'file' }, + }, +} satisfies ToolConfig + +describe('FileToolProcessor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUploadExecutionFile.mockResolvedValue({ + id: 'file-1', + key: 'workspace/workspace-1/file-1', + name: 'avatar.png', + size: 12, + type: 'image/png', + url: '/api/files/serve?key=workspace%2Fworkspace-1%2Ffile-1', + } satisfies UserFile) + }) + + it('caps URL downloads and stores raster images using byte-derived metadata', async () => { + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(4), + ]) + mockDownloadFileFromUrl.mockResolvedValue(png) + + await FileToolProcessor.processToolOutputs( + { + file: { + name: 'avatar.jpg', + mimeType: 'image/jpeg', + url: 'https://example.com/avatar', + }, + }, + toolConfig, + executionContext + ) + + expect(mockDownloadFileFromUrl).toHaveBeenCalledWith('https://example.com/avatar', { + maxBytes: MAX_FILE_SIZE, + userId: 'user-1', + }) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + expect.objectContaining({ executionId: 'execution-1' }), + png, + 'avatar.png', + 'image/png', + 'user-1' + ) + }) + + it('rejects oversized in-memory tool files before upload', async () => { + const oversizedBuffer = Buffer.alloc(1) + Object.defineProperty(oversizedBuffer, 'length', { value: MAX_FILE_SIZE + 1 }) + + await expect( + FileToolProcessor.processToolOutputs( + { + file: { + data: oversizedBuffer, + name: 'oversized.bin', + mimeType: 'application/octet-stream', + }, + }, + toolConfig, + executionContext + ) + ).rejects.toThrow('exceeds the maximum allowed size') + + expect(mockUploadExecutionFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/utils/file-tool-processor.ts b/apps/sim/executor/utils/file-tool-processor.ts index eabac4b35c8..72bdaa3bad3 100644 --- a/apps/sim/executor/utils/file-tool-processor.ts +++ b/apps/sim/executor/utils/file-tool-processor.ts @@ -3,11 +3,49 @@ import { toError } from '@sim/utils/errors' import { isUserFile } from '@/lib/core/utils/user-file' import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' +import { MAX_FILE_SIZE, sniffImageContentType } from '@/lib/uploads/utils/validation' import type { ExecutionContext, UserFile } from '@/executor/types' import type { ToolConfig, ToolFileData } from '@/tools/types' const logger = createLogger('FileToolProcessor') +const IMAGE_FILE_EXTENSIONS: Record = { + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +} + +function assertFileSize(size: number, fileName: string): void { + if (size > MAX_FILE_SIZE) { + throw new Error(`File '${fileName}' exceeds the maximum allowed size of ${MAX_FILE_SIZE} bytes`) + } +} + +function resolveStoredFileMetadata( + fileName: string, + declaredMimeType: string, + buffer: Buffer +): { fileName: string; mimeType: string } { + if (!declaredMimeType.startsWith('image/')) { + return { fileName, mimeType: declaredMimeType } + } + + const mimeType = sniffImageContentType(buffer) + if (!mimeType) { + return { + fileName: `${fileName.replace(/\.[^.]+$/, '')}.bin`, + mimeType: 'application/octet-stream', + } + } + + const extension = IMAGE_FILE_EXTENSIONS[mimeType] + return { + fileName: extension ? `${fileName.replace(/\.[^.]+$/, '')}.${extension}` : fileName, + mimeType, + } +} + /** * Processes tool outputs and converts file-typed outputs to UserFile objects. * This enables tools to return file data that gets automatically stored in the @@ -90,9 +128,11 @@ export class FileToolProcessor { throw new Error(`Output '${outputKey}' is marked as file[] but is not an array`) } - return Promise.all( - fileData.map((file, index) => FileToolProcessor.processFileData(file, executionContext)) - ) + const files: UserFile[] = [] + for (const file of fileData) { + files.push(await FileToolProcessor.processFileData(file, executionContext)) + } + return files } /** @@ -114,6 +154,7 @@ export class FileToolProcessor { let buffer: Buffer | null = null if (Buffer.isBuffer(data.data)) { + assertFileSize(data.data.length, data.name) buffer = data.data } else if ( data.data && @@ -123,6 +164,7 @@ export class FileToolProcessor { ) { const serializedBuffer = data.data as { type: string; data: number[] } if (serializedBuffer.type === 'Buffer' && Array.isArray(serializedBuffer.data)) { + assertFileSize(serializedBuffer.data.length, data.name) buffer = Buffer.from(serializedBuffer.data) } else { throw new Error(`Invalid serialized buffer format for ${data.name}`) @@ -134,17 +176,24 @@ export class FileToolProcessor { base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/') } + const paddingBytes = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0 + assertFileSize(Math.floor((base64Data.length * 3) / 4) - paddingBytes, data.name) buffer = Buffer.from(base64Data, 'base64') } if (!buffer && data.url) { - buffer = await downloadFileFromUrl(data.url, { userId: context.userId }) + buffer = await downloadFileFromUrl(data.url, { + maxBytes: MAX_FILE_SIZE, + userId: context.userId, + }) } if (buffer) { if (buffer.length === 0) { throw new Error(`File '${data.name}' has zero bytes`) } + assertFileSize(buffer.length, data.name) + const storedMetadata = resolveStoredFileMetadata(data.name, data.mimeType, buffer) return await uploadExecutionFile( { @@ -153,8 +202,8 @@ export class FileToolProcessor { executionId: context.executionId || '', }, buffer, - data.name, - data.mimeType, + storedMetadata.fileName, + storedMetadata.mimeType, context.userId ) } diff --git a/apps/sim/lib/api/contracts/tools/instagram.test.ts b/apps/sim/lib/api/contracts/tools/instagram.test.ts new file mode 100644 index 00000000000..80fbb7109f9 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/instagram.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + instagramCarouselMediaSchema, + instagramDownloadMediaOutputSchema, +} from '@/lib/api/contracts/tools/instagram' + +const userFile = { + id: 'file-1', + name: 'image.jpg', + url: '/api/files/serve/execution/image.jpg', + size: 10, + type: 'image/jpeg', + key: 'execution/workflow-1/execution-1/image.jpg', + context: 'execution', +} + +describe('Instagram tool contracts', () => { + it('accepts only 2-10 carousel files', () => { + expect(instagramCarouselMediaSchema.safeParse([userFile]).success).toBe(false) + expect(instagramCarouselMediaSchema.safeParse([userFile, userFile]).success).toBe(true) + expect(instagramCarouselMediaSchema.safeParse(Array(11).fill(userFile)).success).toBe(false) + expect(instagramCarouselMediaSchema.safeParse('https://example.com/one.jpg').success).toBe( + false + ) + expect( + instagramCarouselMediaSchema.safeParse( + 'https://example.com/one.jpg,https://example.com/two.jpg' + ).success + ).toBe(true) + }) + + it('requires 1-10 downloaded files in successful output', () => { + expect( + instagramDownloadMediaOutputSchema.safeParse({ + files: [], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }).success + ).toBe(false) + expect( + instagramDownloadMediaOutputSchema.safeParse({ + files: [userFile], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }).success + ).toBe(true) + expect( + instagramDownloadMediaOutputSchema.safeParse({ + files: [userFile], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 2, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/tools/instagram.ts b/apps/sim/lib/api/contracts/tools/instagram.ts index 876b2a6b27d..68548cf3730 100644 --- a/apps/sim/lib/api/contracts/tools/instagram.ts +++ b/apps/sim/lib/api/contracts/tools/instagram.ts @@ -1,66 +1,169 @@ import { z } from 'zod' +import { + nonEmptyIdSchema, + userFileSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' 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' +import { RawFileInputArraySchema, RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' + +const MAX_ACCESS_TOKEN_LENGTH = 8192 +const MAX_GRAPH_ID_LENGTH = 256 +const MAX_MEDIA_URL_LENGTH = 8192 +const MAX_CAROUSEL_INPUT_LENGTH = 100_000 +const MAX_CAPTION_LENGTH = 2200 +const MAX_ALT_TEXT_LENGTH = 1000 + +const instagramOptionalUserIdSchema = z + .string() + .trim() + .max(MAX_GRAPH_ID_LENGTH, 'Instagram user ID is too long') + .optional() + .nullable() + +const instagramOptionalCaptionSchema = z + .string() + .max(MAX_CAPTION_LENGTH, `Caption cannot exceed ${MAX_CAPTION_LENGTH} characters`) + .optional() + .nullable() + +function getCarouselItemCount(value: string): number | null { + if (value.startsWith('[') || value.startsWith('{')) { + try { + const parsed = JSON.parse(value) as unknown + return Array.isArray(parsed) ? parsed.length : 1 + } catch { + return null + } + } + + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean).length +} + +export const instagramAccessTokenSchema = z + .string() + .min(1, 'Access token is required') + .max(MAX_ACCESS_TOKEN_LENGTH, 'Access token is too long') + +export const instagramDownloadMediaBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + mediaId: z.string().trim().min(1, 'Media ID is required').max(256, 'Media ID is too long'), + filename: z + .string() + .trim() + .min(1, 'Filename cannot be empty') + .max(180, 'Filename is too long') + .optional(), + workspaceId: workspaceIdSchema.optional(), + workflowId: workflowIdSchema.optional(), + executionId: nonEmptyIdSchema.optional(), +}) + +export const instagramDownloadMediaOutputSchema = z + .object({ + files: z.array(userFileSchema).min(1, 'At least one downloaded file is required').max(10), + mediaId: z.string().min(1).max(MAX_GRAPH_ID_LENGTH), + mediaType: z.string().max(64).nullable(), + downloadedCount: z.number().int().min(1).max(10), + }) + .superRefine((output, context) => { + if (output.downloadedCount !== output.files.length) { + context.addIssue({ + code: 'custom', + path: ['downloadedCount'], + message: 'Downloaded count must match the number of files', + }) + } + }) -export const instagramAccessTokenSchema = z.string().min(1, 'Access token is required') +export const instagramDownloadMediaResponseSchema = z.discriminatedUnion('success', [ + z.object({ + success: z.literal(true), + output: instagramDownloadMediaOutputSchema, + }), + z.object({ + success: z.literal(false), + error: z.string().min(1), + }), +]) /** Single media: uploaded file object or public HTTPS URL string. */ -export const instagramMediaInputSchema = FileInputSchema +export const instagramMediaInputSchema = z.union([ + RawFileInputSchema, + z.string().trim().min(1, 'Media is required').max(MAX_MEDIA_URL_LENGTH, 'Media URL is too long'), +]) /** - * Carousel media: file array, single file, or legacy comma-separated URL string + * Carousel media: 2-10 files or a 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'), + RawFileInputArraySchema.min(2, 'Carousels require at least 2 items').max( + 10, + 'Carousels support at most 10 items' + ), + z + .string() + .trim() + .min(1, 'Carousel media is required') + .max(MAX_CAROUSEL_INPUT_LENGTH, 'Carousel media input is too long') + .refine( + (value) => { + const itemCount = getCarouselItemCount(value) + return itemCount !== null && itemCount >= 2 && itemCount <= 10 + }, + { message: 'Carousels require between 2 and 10 items' } + ), ]) export const instagramPublishImageBodySchema = z.object({ accessToken: instagramAccessTokenSchema, - igUserId: z.string().optional().nullable(), + igUserId: instagramOptionalUserIdSchema, image: instagramMediaInputSchema, - caption: z.string().optional().nullable(), - altText: z.string().optional().nullable(), + caption: instagramOptionalCaptionSchema, + altText: z + .string() + .max(MAX_ALT_TEXT_LENGTH, `Alt text cannot exceed ${MAX_ALT_TEXT_LENGTH} characters`) + .optional() + .nullable(), isAiGenerated: z.boolean().optional().nullable(), }) export const instagramPublishVideoBodySchema = z.object({ accessToken: instagramAccessTokenSchema, - igUserId: z.string().optional().nullable(), + igUserId: instagramOptionalUserIdSchema, video: instagramMediaInputSchema, cover: instagramMediaInputSchema.optional().nullable(), - caption: z.string().optional().nullable(), + caption: instagramOptionalCaptionSchema, }) export const instagramPublishReelBodySchema = z.object({ accessToken: instagramAccessTokenSchema, - igUserId: z.string().optional().nullable(), + igUserId: instagramOptionalUserIdSchema, video: instagramMediaInputSchema, cover: instagramMediaInputSchema.optional().nullable(), - caption: z.string().optional().nullable(), + caption: instagramOptionalCaptionSchema, shareToFeed: z.boolean().optional().nullable(), thumbOffset: z.number().optional().nullable(), }) export const instagramPublishStoryBodySchema = z.object({ accessToken: instagramAccessTokenSchema, - igUserId: z.string().optional().nullable(), + igUserId: instagramOptionalUserIdSchema, media: instagramMediaInputSchema, }) export const instagramPublishCarouselBodySchema = z.object({ accessToken: instagramAccessTokenSchema, - igUserId: z.string().optional().nullable(), + igUserId: instagramOptionalUserIdSchema, media: instagramCarouselMediaSchema, - caption: z.string().optional().nullable(), + caption: instagramOptionalCaptionSchema, }) export const instagramPublishImageContract = defineRouteContract({ @@ -98,6 +201,18 @@ export const instagramPublishCarouselContract = defineRouteContract({ response: { mode: 'json', schema: genericToolResponseSchema }, }) +export const instagramDownloadMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/download-media', + body: instagramDownloadMediaBodySchema, + response: { mode: 'json', schema: instagramDownloadMediaResponseSchema }, +}) + +export type InstagramDownloadMediaBody = ContractBodyInput +export type InstagramDownloadMediaRouteResponse = ContractJsonResponse< + typeof instagramDownloadMediaContract +> + export type InstagramPublishImageBody = ContractBodyInput export type InstagramPublishVideoBody = ContractBodyInput export type InstagramPublishReelBody = ContractBodyInput diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 29f2826026f..61a104eccf2 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto' import { cache } from 'react' +import { getOAuth2Tokens } from '@better-auth/core/oauth2' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' import { db } from '@sim/db' @@ -31,6 +32,8 @@ import { renderWelcomeEmail, } from '@/components/emails' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' +import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { @@ -76,6 +79,10 @@ import { isSsoEnabled, } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' +import { + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -83,20 +90,17 @@ import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email import { quickValidateEmail } from '@/lib/messaging/email/validation' import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' -import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' -import { disableUserResources } from '@/lib/workflows/lifecycle' -import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' -import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' -import { getRequestedSignInProviderId, isSignInProviderAllowed } from './constants' - -const logger = createLogger('Auth') - import { deriveMicrosoftEmailVerified, getMicrosoftRefreshTokenExpiry, isMicrosoftProvider, } from '@/lib/oauth/microsoft' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' +import { disableUserResources } from '@/lib/workflows/lifecycle' +import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' + +const logger = createLogger('Auth') /** * Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me. @@ -1947,22 +1951,40 @@ export const auth = betterAuth({ scopes: getCanonicalScopesForProvider('tiktok'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, - // TikTok requires the app identifier under `client_key`, not the standard - // `client_id`. The library always sends `client_id` too, but TikTok ignores - // unrecognized params, so adding `client_key` here (for both the authorize - // redirect and the token exchange) is sufficient without patching the library. - // - // TikTok also requires a comma-separated `scope` list, but the library always - // joins scopes with a space (no configurable separator in the generic-oauth - // plugin's authorize route). `additionalParams` is applied via - // `url.searchParams.set(key, value)` after the default scope is set, so - // overriding `scope` here replaces the space-joined value with a comma-joined - // one that TikTok can actually parse. authorizationUrlParams: { client_key: env.TIKTOK_CLIENT_ID as string, scope: getCanonicalScopesForProvider('tiktok').join(','), }, - tokenUrlParams: { client_key: env.TIKTOK_CLIENT_ID as string }, + getToken: async ({ code, redirectURI }) => { + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: env.TIKTOK_CLIENT_ID as string, + client_secret: env.TIKTOK_CLIENT_SECRET as string, + code, + grant_type: 'authorization_code', + redirect_uri: redirectURI, + }), + }) + const data = await readResponseJsonWithLimit>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok OAuth token response', + }) + + if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error(`TikTok OAuth token exchange failed with HTTP ${response.status}`) + } + + const tokens = getOAuth2Tokens(data) + if (!tokens.accessToken) { + throw new Error('TikTok OAuth token response did not include an access token') + } + if (typeof data.scope === 'string') { + tokens.scopes = data.scope.split(/[\s,]+/).filter(Boolean) + } + return tokens + }, getUserInfo: async (tokens) => { try { const response = await fetch( @@ -1975,7 +1997,10 @@ export const auth = betterAuth({ ) if (!response.ok) { - await response.text().catch(() => {}) + await readResponseTextWithLimit(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile error response', + }).catch(() => {}) logger.error('Error fetching TikTok user info:', { status: response.status, statusText: response.statusText, @@ -1983,7 +2008,18 @@ export const auth = betterAuth({ return null } - const profile = await response.json() + const profile = await readResponseJsonWithLimit<{ + data?: { + user?: { + avatar_url?: string + display_name?: string + open_id?: string + } + } + }>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile response', + }) const user = profile.data?.user if (!user?.open_id) { diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 74a5cf220b7..bd6450d18c8 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -111,7 +111,6 @@ import { IdentityCenterIcon, IncidentioIcon, InfisicalIcon, - InstagramIcon, InstantlyIcon, IntercomIcon, JinaAIIcon, @@ -350,7 +349,6 @@ export const blockTypeToIconMap: Record = { imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, - instagram: InstagramIcon, instantly: InstantlyIcon, intercom_v2: IntercomIcon, jina: JinaAIIcon, diff --git a/apps/sim/lib/integrations/instagram.ts b/apps/sim/lib/integrations/instagram/constants.ts similarity index 100% rename from apps/sim/lib/integrations/instagram.ts rename to apps/sim/lib/integrations/instagram/constants.ts diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/lib/integrations/instagram/resolve-media.ts index 5f4e9c62e86..83208f6a71b 100644 --- a/apps/sim/lib/integrations/instagram/resolve-media.ts +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -250,7 +250,6 @@ export async function resolveInstagramMedia( 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: { @@ -309,7 +308,7 @@ export async function resolveInstagramCarouselMedia( const parsed = JSON.parse(trimmed) as unknown return resolveInstagramCarouselMedia(parsed, userId, requestId, logger) } catch { - // fall through to comma-separated URL parsing + return { error: { status: 400, message: 'Carousel media JSON is invalid' } } } } diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 6ea0f14835b..4384eb6c34f 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -9024,118 +9024,6 @@ "integrationType": "security", "tags": ["secrets-management"] }, - { - "type": "instagram", - "slug": "instagram", - "name": "Instagram", - "description": "Publish content, moderate comments, and manage Instagram DMs", - "longDescription": "Integrate Instagram into workflows. Publish images, videos, Reels, stories, and carousels; moderate comments; send DMs; and pull account or media insights.", - "bgColor": "radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)", - "iconName": "InstagramIcon", - "docsUrl": "https://docs.sim.ai/integrations/instagram", - "operations": [ - { - "name": "Get Profile", - "description": "Get the connected Instagram professional account profile" - }, - { - "name": "List Media", - "description": "List recent media on the Instagram professional account" - }, - { - "name": "Get Media", - "description": "Get details for a specific Instagram media object" - }, - { - "name": "List Stories", - "description": "List active stories on the Instagram professional account" - }, - { - "name": "Publish Image", - "description": "Create and publish a single JPEG image post from an uploaded file or public HTTPS URL (polls until the container is ready)" - }, - { - "name": "Publish Video", - "description": "Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready)" - }, - { - "name": "Publish Reel", - "description": "Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready)" - }, - { - "name": "Publish Story", - "description": "Publish an image or video story from an uploaded file or public HTTPS URL" - }, - { - "name": "Publish Carousel", - "description": "Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs" - }, - { - "name": "Get Container Status", - "description": "Check the publishing status of a media container" - }, - { - "name": "Get Publishing Limit", - "description": "Check the content publishing rate limit usage for the account" - }, - { - "name": "List Comments", - "description": "List comments on an Instagram media object" - }, - { - "name": "Reply to Comment", - "description": "Reply to a comment on Instagram media" - }, - { - "name": "Hide Comment", - "description": "Hide or unhide a comment on Instagram media" - }, - { - "name": "Delete Comment", - "description": "Delete a comment on Instagram media" - }, - { - "name": "Set Comments Enabled", - "description": "Enable or disable comments on an Instagram media object" - }, - { - "name": "Private Reply", - "description": "Send a private Direct message reply to a commenter (one per commenter)" - }, - { - "name": "List Conversations", - "description": "List Instagram Direct conversations for the professional account" - }, - { - "name": "Get Conversation Messages", - "description": "List recent message ids/content in a conversation (only the last ~20 messages are fetchable)" - }, - { - "name": "Get Message", - "description": "Get a single Instagram Direct message by id (only recent messages are available)" - }, - { - "name": "Send Text Message", - "description": "Send a text Direct message. The recipient must have messaged the account first (24h window)." - }, - { - "name": "Get Account Insights", - "description": "Get insights metrics for the Instagram professional account" - }, - { - "name": "Get Media Insights", - "description": "Get insights metrics for a specific Instagram media object" - } - ], - "operationCount": 23, - "triggers": [], - "triggerCount": 0, - "authType": "oauth", - "oauthServiceId": "instagram", - "category": "tools", - "integrationType": "marketing", - "tags": ["marketing", "messaging", "automation"] - }, { "type": "instantly", "slug": "instantly", diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index a5ad87e2f2b..60b66c23b32 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -9,6 +9,8 @@ vi.mock('@/lib/core/config/env', () => GITHUB_CLIENT_SECRET: 'github_client_secret', X_CLIENT_ID: 'x_client_id', X_CLIENT_SECRET: 'x_client_secret', + TIKTOK_CLIENT_ID: 'tiktok_client_key', + TIKTOK_CLIENT_SECRET: 'tiktok_client_secret', CONFLUENCE_CLIENT_ID: 'confluence_client_id', CONFLUENCE_CLIENT_SECRET: 'confluence_client_secret', JIRA_CLIENT_ID: 'jira_client_id', @@ -271,6 +273,22 @@ describe('OAuth Token Refresh', () => { ) }) + it.concurrent('should refresh TikTok with client_key instead of client_id', async () => { + const mockFetch = createMockFetch(defaultOAuthResponse) + const refreshToken = 'test_refresh_token' + + await withMockFetch(mockFetch, () => refreshOAuthToken('tiktok', refreshToken)) + + const [endpoint, requestOptions] = mockFetch.mock.calls[0] as [string, { body: string }] + const bodyParams = new URLSearchParams(requestOptions.body) + + expect(endpoint).toBe('https://open.tiktokapis.com/v2/oauth/token/') + expect(bodyParams.get('client_key')).toBe('tiktok_client_key') + expect(bodyParams.get('client_secret')).toBe('tiktok_client_secret') + expect(bodyParams.get('refresh_token')).toBe(refreshToken) + expect(bodyParams.get('client_id')).toBeNull() + }) + it.concurrent('should send Notion request with Basic Auth header and JSON body', async () => { const mockFetch = createMockFetch(defaultOAuthResponse) const refreshToken = 'test_refresh_token' diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 31ce9b6c0b7..cc230df8108 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -176,6 +176,8 @@ export interface DownloadFileFromUrlOptions { timeoutMs?: number /** Hard cap on the number of bytes read from the source. */ maxBytes?: number + /** Cancels an external download when the surrounding request or execution stops. */ + signal?: AbortSignal /** * Principal the download is performed on behalf of. Required to authorize * internal (`/api/files/serve/...`) URLs: the resolved storage key is checked @@ -203,7 +205,9 @@ export async function downloadFileFromUrl( fileUrl: string, options: DownloadFileFromUrlOptions = {} ): Promise { - const { timeoutMs = getMaxExecutionTimeout(), maxBytes, userId } = options + const { timeoutMs = getMaxExecutionTimeout(), maxBytes, signal, userId } = options + + signal?.throwIfAborted() if (isInternalFileUrl(fileUrl)) { if (!userId) { @@ -237,6 +241,7 @@ export async function downloadFileFromUrl( const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, { timeout: timeoutMs, maxResponseBytes: maxBytes, + signal, }) if (!response.ok) { diff --git a/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts b/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts index e0ac0d04b84..7af9c6ca373 100644 --- a/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts +++ b/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts @@ -4,13 +4,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCredentialExpression, mockEq, mockSelect, queryRows } = vi.hoisted(() => ({ +const { + mockAsc, + mockCredentialExpression, + mockEq, + mockGt, + mockLike, + mockLimit, + mockOrderBy, + mockSelect, + queryRows, +} = vi.hoisted(() => ({ + mockAsc: vi.fn((value: unknown) => ({ asc: value })), mockCredentialExpression: vi.fn(() => 'webhook.credentialId'), mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })), + mockGt: vi.fn((left: unknown, right: unknown) => ({ gt: [left, right] })), + mockLike: vi.fn((left: unknown, right: unknown) => ({ left, right })), + mockLimit: vi.fn(), + mockOrderBy: vi.fn(), mockSelect: vi.fn(), queryRows: { rows: [] as Array<{ accountId: string + webhookId: string webhook: Record workflow: Record }>, @@ -22,8 +38,12 @@ vi.mock('@sim/db', () => { from: vi.fn(() => chain), innerJoin: vi.fn(() => chain), leftJoin: vi.fn(() => chain), - where: vi.fn(() => Promise.resolve(queryRows.rows)), + where: vi.fn(() => chain), + orderBy: mockOrderBy, + limit: mockLimit, } + mockOrderBy.mockImplementation(() => chain) + mockLimit.mockImplementation((limit: number) => Promise.resolve(queryRows.rows.slice(0, limit))) mockSelect.mockImplementation(() => chain) return { @@ -44,6 +64,7 @@ vi.mock('@sim/db', () => { webhook: { deploymentVersionId: 'webhook.deploymentVersionId', isActive: 'webhook.isActive', + id: 'webhook.id', archivedAt: 'webhook.archivedAt', provider: 'webhook.provider', providerConfig: 'webhook.providerConfig', @@ -64,15 +85,22 @@ vi.mock('@sim/db', () => { vi.mock('drizzle-orm', () => ({ and: vi.fn((...conditions: unknown[]) => conditions), + asc: mockAsc, eq: mockEq, + gt: mockGt, isNull: vi.fn((value: unknown) => ({ isNull: value })), - like: vi.fn((left: unknown, right: unknown) => ({ left, right })), + like: mockLike, or: vi.fn((...conditions: unknown[]) => conditions), })) -import { findTikTokWebhookTargets } from '@/lib/webhooks/providers/tiktok-targets' +import { + findTikTokWebhookTargetPage, + TIKTOK_WEBHOOK_TARGET_PAGE_SIZE, +} from '@/lib/webhooks/providers/tiktok-targets' -describe('findTikTokWebhookTargets', () => { +const ACCOUNT_UUID = '11111111-2222-3333-4444-555555555555' + +describe('findTikTokWebhookTargetPage', () => { beforeEach(() => { vi.clearAllMocks() queryRows.rows = [] @@ -81,29 +109,35 @@ describe('findTikTokWebhookTargets', () => { it('returns only rows whose stored account ID exactly matches user_openid', async () => { queryRows.rows = [ { - accountId: 'act.user-11111111-2222-3333-4444-555555555555', + accountId: `act.user-${ACCOUNT_UUID}`, + webhookId: 'webhook-1', webhook: { id: 'webhook-1' }, workflow: { id: 'workflow-1' }, }, { - accountId: 'act.user-other-11111111-2222-3333-4444-555555555555', + accountId: `act.user-other-${ACCOUNT_UUID}`, + webhookId: 'webhook-2', webhook: { id: 'webhook-2' }, workflow: { id: 'workflow-2' }, }, ] - const targets = await findTikTokWebhookTargets('act.user', 'request-1') + const page = await findTikTokWebhookTargetPage('act.user', 'request-1') - expect(targets).toEqual([ - { - webhook: { id: 'webhook-1' }, - workflow: { id: 'workflow-1' }, - }, - ]) + expect(page).toEqual({ + hasMore: false, + nextCursor: 'webhook-2', + targets: [ + { + webhook: { id: 'webhook-1' }, + workflow: { id: 'workflow-1' }, + }, + ], + }) }) it('enforces provider and workspace bindings in the database query', async () => { - await findTikTokWebhookTargets('act.user', 'request-2') + await findTikTokWebhookTargetPage('act.user', 'request-2') expect(mockEq).toHaveBeenCalledWith('credential.providerId', 'tiktok') expect(mockEq).toHaveBeenCalledWith('webhook.provider', 'tiktok') @@ -112,8 +146,48 @@ describe('findTikTokWebhookTargets', () => { expect(mockEq).toHaveBeenCalledWith('webhook.credentialId', 'credential.id') }) + it('uses a fixed-size webhook ID keyset in ascending order', async () => { + await findTikTokWebhookTargetPage('act.user', 'request-3', 'webhook-100') + + expect(mockGt).toHaveBeenCalledWith('webhook.id', 'webhook-100') + expect(mockAsc).toHaveBeenCalledWith('webhook.id') + expect(mockOrderBy).toHaveBeenCalledWith({ asc: 'webhook.id' }) + expect(mockLimit).toHaveBeenCalledWith(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) + }) + + it('returns a continuation cursor when the fixed-size page is full', async () => { + queryRows.rows = Array.from({ length: TIKTOK_WEBHOOK_TARGET_PAGE_SIZE + 1 }, (_, index) => { + const webhookId = `webhook-${String(index).padStart(3, '0')}` + return { + accountId: `act.user-${ACCOUNT_UUID}`, + webhookId, + webhook: { id: webhookId }, + workflow: { id: `workflow-${index}` }, + } + }) + + const page = await findTikTokWebhookTargetPage('act.user', 'request-4') + + expect(page.targets).toHaveLength(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) + expect(page.hasMore).toBe(true) + expect(page.nextCursor).toBe('webhook-099') + }) + + it('escapes user_openid wildcard characters in the account lookup', async () => { + await findTikTokWebhookTargetPage('act_%', 'request-5') + + expect(mockLike).toHaveBeenCalledWith( + 'account.accountId', + 'act\\_\\%-________-____-____-____-____________' + ) + }) + it('does not query for an empty user_openid', async () => { - expect(await findTikTokWebhookTargets('', 'request-3')).toEqual([]) + expect(await findTikTokWebhookTargetPage('', 'request-6')).toEqual({ + hasMore: false, + nextCursor: null, + targets: [], + }) expect(mockSelect).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/webhooks/providers/tiktok-targets.ts b/apps/sim/lib/webhooks/providers/tiktok-targets.ts index 8fc4dc2aeaf..79ab0448dfe 100644 --- a/apps/sim/lib/webhooks/providers/tiktok-targets.ts +++ b/apps/sim/lib/webhooks/providers/tiktok-targets.ts @@ -8,16 +8,25 @@ import { workflowDeploymentVersion, } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq, isNull, like, or } from 'drizzle-orm' +import { and, asc, eq, gt, isNull, like, or } from 'drizzle-orm' const logger = createLogger('TikTokWebhookTargets') const ACCOUNT_ID_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const ACCOUNT_ID_UUID_LIKE_SUFFIX = '________-____-____-____-____________' + +export const TIKTOK_WEBHOOK_TARGET_PAGE_SIZE = 100 export interface TikTokWebhookTarget { webhook: typeof webhook.$inferSelect workflow: typeof workflow.$inferSelect } +export interface TikTokWebhookTargetPage { + hasMore: boolean + nextCursor: string | null + targets: TikTokWebhookTarget[] +} + function escapeLikePattern(value: string): string { return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') } @@ -27,18 +36,24 @@ function openIdFromAccountId(accountId: string): string { } /** - * Resolves a TikTok user_openid to active webhook targets through the credential ID persisted in - * providerConfig. The workflow-workspace equality prevents cross-tenant event routing. + * Resolves one deterministic page of active webhook targets for a TikTok user_openid through the + * credential ID persisted in providerConfig. The workflow-workspace equality prevents + * cross-tenant event routing, while the webhook ID cursor keeps each query and retained result set + * bounded without offset drift. */ -export async function findTikTokWebhookTargets( +export async function findTikTokWebhookTargetPage( userOpenId: string, - requestId: string -): Promise { - if (!userOpenId) return [] + requestId: string, + afterWebhookId?: string +): Promise { + if (!userOpenId) { + return { hasMore: false, nextCursor: null, targets: [] } + } const rows = await db .select({ accountId: account.accountId, + webhookId: webhook.id, webhook, workflow, }) @@ -78,13 +93,16 @@ export async function findTikTokWebhookTargets( .where( and( eq(account.providerId, 'tiktok'), - like(account.accountId, `${escapeLikePattern(userOpenId)}-%`), + like(account.accountId, `${escapeLikePattern(userOpenId)}-${ACCOUNT_ID_UUID_LIKE_SUFFIX}`), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) + ), + afterWebhookId ? gt(webhook.id, afterWebhookId) : undefined ) ) + .orderBy(asc(webhook.id)) + .limit(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) const targets = rows .filter((row) => openIdFromAccountId(row.accountId) === userOpenId) @@ -92,11 +110,14 @@ export async function findTikTokWebhookTargets( webhook: webhookRecord, workflow: workflowRecord, })) + const nextCursor = rows.at(-1)?.webhookId ?? null + const hasMore = rows.length === TIKTOK_WEBHOOK_TARGET_PAGE_SIZE - logger.info(`[${requestId}] Resolved TikTok webhook targets`, { + logger.info(`[${requestId}] Resolved TikTok webhook target page`, { + hasMore, userOpenIdPrefix: userOpenId.slice(0, 12), - webhookCount: targets.length, + targetCount: targets.length, }) - return targets + return { hasMore, nextCursor, targets } } diff --git a/apps/sim/tools/instagram/delete_comment.ts b/apps/sim/tools/instagram/delete_comment.ts index 33bb7e84dfe..f3216b15c9a 100644 --- a/apps/sim/tools/instagram/delete_comment.ts +++ b/apps/sim/tools/instagram/delete_comment.ts @@ -2,7 +2,7 @@ import type { InstagramDeleteCommentParams, InstagramDeleteCommentResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, readGraphError, readGraphJson } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramDeleteCommentTool: ToolConfig< @@ -49,10 +49,25 @@ export const instagramDeleteCommentTool: ToolConfig< } } - const data = await response.json().catch(() => ({ success: true })) + if (response.status === 204) { + return { success: true, output: { success: true } } + } + + const data = await readGraphJson<{ success?: boolean }>( + response, + 'Instagram delete comment response' + ).catch(() => null) + if (!data || data.success !== true) { + return { + success: false, + output: { success: false }, + error: 'Instagram did not confirm that the comment was deleted', + } + } + return { success: true, - output: { success: data.success !== false }, + output: { success: true }, } }, diff --git a/apps/sim/tools/instagram/download_media.ts b/apps/sim/tools/instagram/download_media.ts new file mode 100644 index 00000000000..4fe39626d21 --- /dev/null +++ b/apps/sim/tools/instagram/download_media.ts @@ -0,0 +1,139 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { + type InstagramDownloadMediaBody, + instagramDownloadMediaResponseSchema, +} from '@/lib/api/contracts/tools/instagram' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + InstagramDownloadMediaParams, + InstagramDownloadMediaResponse, +} from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +const MAX_INTERNAL_RESPONSE_BYTES = 256 * 1024 + +function failureOutput(mediaId: string): InstagramDownloadMediaResponse['output'] { + return { + files: [], + mediaId, + mediaType: null, + downloadedCount: 0, + } +} + +export const instagramDownloadMediaTool: ToolConfig< + InstagramDownloadMediaParams, + InstagramDownloadMediaResponse +> = { + id: 'instagram_download_media', + name: 'Instagram Download Media', + description: + 'Download an Instagram image, video, Reel, story, or ordered carousel into canonical User Files for downstream file inputs', + 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 to download', + }, + filename: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional filename override; carousel items receive an ordered suffix', + }, + }, + + request: { + url: '/api/tools/instagram/download-media', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => + ({ + accessToken: params.accessToken, + mediaId: params.mediaId, + filename: params.filename, + workspaceId: + typeof params._context?.workspaceId === 'string' + ? params._context.workspaceId + : undefined, + workflowId: + typeof params._context?.workflowId === 'string' ? params._context.workflowId : undefined, + executionId: + typeof params._context?.executionId === 'string' + ? params._context.executionId + : undefined, + }) satisfies InstagramDownloadMediaBody, + }, + + transformResponse: async (response, params): Promise => { + const mediaId = params?.mediaId?.trim() ?? '' + + try { + const rawData = await readResponseJsonWithLimit(response, { + maxBytes: MAX_INTERNAL_RESPONSE_BYTES, + label: 'Instagram download media response', + }) + const parsed = instagramDownloadMediaResponseSchema.safeParse(rawData) + + if (!parsed.success) { + return { + success: false, + output: failureOutput(mediaId), + error: 'Instagram download route returned an invalid response', + } + } + + if (!response.ok || !parsed.data.success) { + return { + success: false, + output: failureOutput(mediaId), + error: + parsed.data.success === false + ? parsed.data.error + : `Instagram download failed with status ${response.status}`, + } + } + + return { + success: true, + output: parsed.data.output, + } + } catch (error) { + return { + success: false, + output: failureOutput(mediaId), + error: getErrorMessage(error, 'Failed to read Instagram download response'), + } + } + }, + + outputs: { + files: { + type: 'file[]', + description: 'Downloaded media as canonical User Files, ready for attachment inputs', + }, + mediaId: { type: 'string', description: 'Instagram media ID that was downloaded' }, + mediaType: { + type: 'string', + description: 'Instagram media type, such as IMAGE, VIDEO, or CAROUSEL_ALBUM', + optional: true, + }, + downloadedCount: { type: 'number', description: 'Number of files downloaded' }, + }, +} diff --git a/apps/sim/tools/instagram/get_account_insights.ts b/apps/sim/tools/instagram/get_account_insights.ts index 3af2b77cab4..64822e0bc95 100644 --- a/apps/sim/tools/instagram/get_account_insights.ts +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -1,3 +1,4 @@ +import { INSTAGRAM_INSIGHT_PROPERTIES } from '@/tools/instagram/output-properties' import type { InstagramGetAccountInsightsParams, InstagramGetAccountInsightsResponse, @@ -7,6 +8,7 @@ import { graphUrl, parseCommaSeparated, readGraphError, + readGraphJson, } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' @@ -48,7 +50,7 @@ export const instagramGetAccountInsightsTool: ToolConfig< type: 'string', required: true, visibility: 'user-or-llm', - description: 'Aggregation period: day, week, days_28, lifetime, or total_over_range', + description: 'Aggregation period: day, week, days_28, month, lifetime, or total_over_range', }, since: { type: 'string', @@ -108,7 +110,10 @@ export const instagramGetAccountInsightsTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson<{ data?: Record[] }>( + response, + 'Instagram account insights response' + ) const items = Array.isArray(data.data) ? data.data : [] return { @@ -128,8 +133,9 @@ export const instagramGetAccountInsightsTool: ToolConfig< outputs: { insights: { - type: 'json', - description: 'Insight metrics (name, period, title, description, values, totalValue)', + type: 'array', + description: 'Account insight metrics', + items: { type: 'object', properties: INSTAGRAM_INSIGHT_PROPERTIES }, }, }, } diff --git a/apps/sim/tools/instagram/get_container_status.ts b/apps/sim/tools/instagram/get_container_status.ts index 43f62f8ee36..851752ec94d 100644 --- a/apps/sim/tools/instagram/get_container_status.ts +++ b/apps/sim/tools/instagram/get_container_status.ts @@ -2,7 +2,13 @@ import type { InstagramGetContainerStatusParams, InstagramGetContainerStatusResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + graphUrl, + idString, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramGetContainerStatusTool: ToolConfig< @@ -53,7 +59,11 @@ export const instagramGetContainerStatusTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson<{ + id?: string | number + status_code?: string + status?: string + }>(response, 'Instagram container status response') return { success: true, output: { diff --git a/apps/sim/tools/instagram/get_conversation_messages.test.ts b/apps/sim/tools/instagram/get_conversation_messages.test.ts new file mode 100644 index 00000000000..d40c0fb2654 --- /dev/null +++ b/apps/sim/tools/instagram/get_conversation_messages.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' +import { instagramGetConversationMessagesTool } from '@/tools/instagram/get_conversation_messages' +import { instagramGetMessageTool } from '@/tools/instagram/get_message' +import { INSTAGRAM_RESPONSE_MAX_BYTES } from '@/tools/instagram/utils' + +describe('instagramGetConversationMessagesTool', () => { + it('applies limit and cursor to the nested messages connection', () => { + const buildUrl = instagramGetConversationMessagesTool.request.url + if (typeof buildUrl !== 'function') throw new Error('Expected a dynamic request URL') + + const url = new URL( + buildUrl({ + accessToken: 'token', + conversationId: ' conversation-1 ', + limit: 7, + after: ' cursor-2 ', + }) + ) + + expect(url.pathname.endsWith('/conversation-1')).toBe(true) + expect(url.searchParams.get('fields')).toBe( + 'messages.limit(7).after(cursor-2){id,created_time,is_unsupported}' + ) + }) + + it('maps documented message fields and nested pagination', async () => { + const result = await instagramGetConversationMessagesTool.transformResponse?.( + Response.json({ + messages: { + data: [ + { + id: 'message-1', + created_time: '2025-07-28T22:44:49+0000', + is_unsupported: true, + }, + { id: 2, created_time: '2025-07-28T21:20:58+0000', is_unsupported: false }, + { created_time: '2025-07-28T21:20:13+0000' }, + ], + paging: { + cursors: { after: 'next-message-cursor' }, + next: 'https://graph.instagram.com/next', + }, + }, + id: 'conversation-1', + }), + { accessToken: 'token', conversationId: ' conversation-1 ' } + ) + + expect(result).toEqual({ + success: true, + output: { + conversationId: 'conversation-1', + messages: [ + { + id: 'message-1', + createdTime: '2025-07-28T22:44:49+0000', + isUnsupported: true, + }, + { + id: '2', + createdTime: '2025-07-28T21:20:58+0000', + isUnsupported: false, + }, + ], + nextCursor: 'next-message-cursor', + }, + }) + expect(instagramGetConversationMessagesTool.outputs?.messages.type).toBe('array') + expect(instagramGetConversationMessagesTool.outputs?.messages.items?.properties).toHaveProperty( + 'isUnsupported' + ) + }) + + it('rejects oversized Graph response bodies', async () => { + const transform = instagramGetConversationMessagesTool.transformResponse + if (!transform) throw new Error('Expected a response transform') + + await expect( + transform( + Response.json({ + messages: { data: [] }, + padding: 'x'.repeat(INSTAGRAM_RESPONSE_MAX_BYTES + 1), + }), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + ).rejects.toThrow( + `Instagram conversation messages response exceeds maximum size of ${INSTAGRAM_RESPONSE_MAX_BYTES} bytes` + ) + }) + + it('allows success responses above the smaller Graph error-body cap', async () => { + const transform = instagramGetConversationMessagesTool.transformResponse + if (!transform) throw new Error('Expected a response transform') + + const result = await transform( + Response.json({ + id: 'conversation-1', + messages: { data: [] }, + padding: 'x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1), + }), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + + expect(result).toMatchObject({ + success: true, + output: { conversationId: 'conversation-1', messages: [] }, + }) + }) + + it('rejects malformed successful Graph responses', async () => { + const transform = instagramGetConversationMessagesTool.transformResponse + if (!transform) throw new Error('Expected a response transform') + + await expect( + transform(new Response('{not-json', { status: 200 }), { + accessToken: 'token', + conversationId: 'conversation-1', + }) + ).rejects.toThrow() + }) + + it('preserves Graph provider error metadata', async () => { + const result = await instagramGetConversationMessagesTool.transformResponse?.( + Response.json( + { + error: { + message: 'Bad request', + type: 'OAuthException', + code: 100, + error_subcode: 33, + fbtrace_id: 'trace-1', + }, + }, + { status: 400 } + ), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + + expect(result?.error).toBe( + 'Bad request (type OAuthException, code 100, subcode 33, trace trace-1)' + ) + }) + + it('caps Graph error bodies at the shared error-response limit', async () => { + const result = await instagramGetConversationMessagesTool.transformResponse?.( + Response.json( + { error: { message: 'x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1) } }, + { status: 400, statusText: 'Bad Request' } + ), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + + expect(result?.error).toBe('Bad Request') + }) +}) + +describe('instagramGetMessageTool', () => { + it('maps the documented sender username', async () => { + const result = await instagramGetMessageTool.transformResponse?.( + Response.json({ + id: 'message-1', + created_time: '2025-07-28T22:44:49+0000', + from: { id: 'sender-1', username: 'sender.username' }, + to: { data: [{ id: 'recipient-1', username: 'recipient.username' }] }, + message: 'Hello', + }) + ) + + expect(result?.output).toMatchObject({ + id: 'message-1', + fromId: 'sender-1', + fromUsername: 'sender.username', + toId: 'recipient-1', + message: 'Hello', + }) + }) +}) diff --git a/apps/sim/tools/instagram/get_conversation_messages.ts b/apps/sim/tools/instagram/get_conversation_messages.ts index 817873fd71c..11c4fd06f49 100644 --- a/apps/sim/tools/instagram/get_conversation_messages.ts +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -1,10 +1,25 @@ +import { INSTAGRAM_MESSAGE_REFERENCE_PROPERTIES } from '@/tools/instagram/output-properties' import type { InstagramGetConversationMessagesParams, InstagramGetConversationMessagesResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + idString, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' +function buildMessagesField(limit: number | undefined, after: string | undefined): string { + const cursor = after?.trim() + const pagination = cursor ? `.after(${cursor})` : '' + return `messages.limit(${clampGraphLimit(limit)})${pagination}{id,created_time,is_unsupported}` +} + export const instagramGetConversationMessagesTool: ToolConfig< InstagramGetConversationMessagesParams, InstagramGetConversationMessagesResponse @@ -12,7 +27,7 @@ export const instagramGetConversationMessagesTool: ToolConfig< 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)', + 'List cursor-paginated message references; full details are available only for the 20 most recent messages', version: '1.0.0', oauth: { @@ -33,12 +48,24 @@ export const instagramGetConversationMessagesTool: ToolConfig< visibility: 'user-or-llm', description: 'Conversation id from list_conversations', }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of message references to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Nested messages pagination cursor', + }, }, request: { url: (params) => graphUrl(`/${params.conversationId.trim()}`, { - fields: 'messages{id,created_time}', + fields: buildMessagesField(params.limit, params.after), }), method: 'GET', headers: (params) => bearerHeaders(params.accessToken), @@ -51,22 +78,43 @@ export const instagramGetConversationMessagesTool: ToolConfig< if (!response.ok) { return { success: false, - output: { conversationId: params?.conversationId ?? '', messages: [] }, + output: { + conversationId: params?.conversationId?.trim() ?? '', + messages: [], + nextCursor: null, + }, error: await readGraphError(response), } } - const data = await response.json() + const data = await readGraphJson<{ + id?: string | number + messages?: InstagramGraphPage> + }>(response, 'Instagram conversation messages response') const items = Array.isArray(data.messages?.data) ? data.messages.data : [] + const messages = items.flatMap((item: Record) => { + const id = + typeof item.id === 'string' || typeof item.id === 'number' ? idString(item.id) : null + if (!id) return [] + + return [ + { + id, + createdTime: typeof item.created_time === 'string' ? item.created_time : null, + isUnsupported: item.is_unsupported === true, + }, + ] + }) return { success: true, output: { - conversationId: params?.conversationId ?? idString(data.id) ?? '', - messages: items.map((item: Record) => ({ - id: String(item.id ?? ''), - createdTime: (item.created_time as string | undefined) ?? null, - })), + conversationId: params?.conversationId?.trim() || idString(data.id) || '', + messages, + nextCursor: + data.messages?.paging?.next && typeof data.messages?.paging?.cursors?.after === 'string' + ? data.messages.paging.cursors.after + : null, }, } }, @@ -74,9 +122,15 @@ export const instagramGetConversationMessagesTool: ToolConfig< outputs: { conversationId: { type: 'string', description: 'Conversation id' }, messages: { - type: 'json', + type: 'array', description: 'Message references (id, createdTime). Use Get Message for sender, recipient, and text.', + items: { type: 'object', properties: INSTAGRAM_MESSAGE_REFERENCE_PROPERTIES }, + }, + nextCursor: { + type: 'string', + description: 'Nested messages pagination cursor', + optional: true, }, }, } diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts index eb1997bfb36..248fd56eba9 100644 --- a/apps/sim/tools/instagram/get_media.ts +++ b/apps/sim/tools/instagram/get_media.ts @@ -1,5 +1,13 @@ +import { INSTAGRAM_CHILD_MEDIA_PROPERTIES } from '@/tools/instagram/output-properties' import type { InstagramGetMediaParams, InstagramGetMediaResponse } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + graphUrl, + type InstagramGraphPage, + idString, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' const MEDIA_FIELDS = @@ -58,9 +66,23 @@ export const instagramGetMediaTool: ToolConfig + }>(response, 'Instagram media response') const children = Array.isArray(data.children?.data) - ? data.children.data.map((child: { id?: string }) => ({ id: String(child.id ?? '') })) + ? data.children.data.flatMap((child: { id?: unknown }) => { + const id = idString(child.id) + return id ? [{ id }] : [] + }) : [] return { @@ -89,11 +111,19 @@ export const instagramGetMediaTool: ToolConfig[] }>( + response, + 'Instagram media insights response' + ) const items = Array.isArray(data.data) ? data.data : [] return { @@ -84,8 +89,9 @@ export const instagramGetMediaInsightsTool: ToolConfig< outputs: { insights: { - type: 'json', - description: 'Media insight metrics (name, period, title, description, values, totalValue)', + type: 'array', + description: 'Media insight metrics', + items: { type: 'object', properties: INSTAGRAM_INSIGHT_PROPERTIES }, }, }, } diff --git a/apps/sim/tools/instagram/get_message.ts b/apps/sim/tools/instagram/get_message.ts index 164b1d44c01..e728da256db 100644 --- a/apps/sim/tools/instagram/get_message.ts +++ b/apps/sim/tools/instagram/get_message.ts @@ -2,7 +2,14 @@ import type { InstagramGetMessageParams, InstagramGetMessageResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + graphUrl, + type InstagramGraphPage, + idString, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramGetMessageTool: ToolConfig< @@ -59,15 +66,36 @@ export const instagramGetMessageTool: ToolConfig< } } - const data = await response.json() - const from = data.from as { id?: string | number; username?: string } | undefined + const data = await readGraphJson<{ + id?: string | number + created_time?: string + from?: { id?: string | number; username?: string } + to?: InstagramGraphPage<{ id?: string | number; username?: string }> + message?: string + }>(response, 'Instagram message response') + const from = data.from const toData = data.to?.data const toFirst = Array.isArray(toData) ? toData[0] : undefined + const id = idString(data.id) + if (!id) { + return { + success: false, + output: { + id: null, + createdTime: null, + fromId: null, + fromUsername: null, + toId: null, + message: null, + }, + error: 'Instagram message response did not include a message id', + } + } return { success: true, output: { - id: idString(data.id), + id, createdTime: data.created_time ?? null, fromId: idString(from?.id), fromUsername: from?.username ?? null, @@ -78,7 +106,7 @@ export const instagramGetMessageTool: ToolConfig< }, outputs: { - id: { type: 'string', description: 'Message id', optional: true }, + id: { type: 'string', description: 'Message id' }, 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 }, diff --git a/apps/sim/tools/instagram/get_profile.ts b/apps/sim/tools/instagram/get_profile.ts index 11b9b43f28f..03205260cc1 100644 --- a/apps/sim/tools/instagram/get_profile.ts +++ b/apps/sim/tools/instagram/get_profile.ts @@ -2,7 +2,13 @@ import type { InstagramGetProfileParams, InstagramGetProfileResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + graphUrl, + idString, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' const PROFILE_FIELDS = @@ -56,7 +62,17 @@ export const instagramGetProfileTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson<{ + user_id?: string | number + id?: string | number + username?: string + name?: string + account_type?: string + profile_picture_url?: string + followers_count?: number + follows_count?: number + media_count?: number + }>(response, 'Instagram profile response') return { success: true, diff --git a/apps/sim/tools/instagram/get_publishing_limit.ts b/apps/sim/tools/instagram/get_publishing_limit.ts index c836b1539fb..97cb80d2d17 100644 --- a/apps/sim/tools/instagram/get_publishing_limit.ts +++ b/apps/sim/tools/instagram/get_publishing_limit.ts @@ -2,9 +2,23 @@ import type { InstagramGetPublishingLimitParams, InstagramGetPublishingLimitResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' +interface PublishingLimitData { + quota_usage?: number + config?: { + quota_total?: number + quota_duration?: number + } +} + export const instagramGetPublishingLimitTool: ToolConfig< InstagramGetPublishingLimitParams, InstagramGetPublishingLimitResponse @@ -54,7 +68,10 @@ export const instagramGetPublishingLimitTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson & PublishingLimitData>( + response, + 'Instagram publishing limit response' + ) const first = Array.isArray(data.data) ? data.data[0] : data return { diff --git a/apps/sim/tools/instagram/hide_comment.ts b/apps/sim/tools/instagram/hide_comment.ts index 244ee806409..7e7d96629d7 100644 --- a/apps/sim/tools/instagram/hide_comment.ts +++ b/apps/sim/tools/instagram/hide_comment.ts @@ -2,7 +2,7 @@ import type { InstagramHideCommentParams, InstagramHideCommentResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, readGraphError, readGraphJson } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramHideCommentTool: ToolConfig< @@ -55,10 +55,21 @@ export const instagramHideCommentTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson<{ success?: boolean }>( + response, + 'Instagram hide comment response' + ) + if (data.success !== true) { + return { + success: false, + output: { success: false }, + error: 'Instagram did not confirm that the comment visibility was updated', + } + } + return { success: true, - output: { success: data.success === true || data.success === undefined }, + output: { success: true }, } }, diff --git a/apps/sim/tools/instagram/index.ts b/apps/sim/tools/instagram/index.ts index 78a7afe6623..8a158afc6e7 100644 --- a/apps/sim/tools/instagram/index.ts +++ b/apps/sim/tools/instagram/index.ts @@ -1,4 +1,5 @@ export { instagramDeleteCommentTool } from '@/tools/instagram/delete_comment' +export { instagramDownloadMediaTool } from '@/tools/instagram/download_media' export { instagramGetAccountInsightsTool } from '@/tools/instagram/get_account_insights' export { instagramGetContainerStatusTool } from '@/tools/instagram/get_container_status' export { instagramGetConversationMessagesTool } from '@/tools/instagram/get_conversation_messages' diff --git a/apps/sim/tools/instagram/list_comments.ts b/apps/sim/tools/instagram/list_comments.ts index e3f5f92b1a9..e831975b3cc 100644 --- a/apps/sim/tools/instagram/list_comments.ts +++ b/apps/sim/tools/instagram/list_comments.ts @@ -1,8 +1,16 @@ +import { INSTAGRAM_COMMENT_PROPERTIES } from '@/tools/instagram/output-properties' import type { InstagramListCommentsParams, InstagramListCommentsResponse, } from '@/tools/instagram/types' -import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramListCommentsTool: ToolConfig< @@ -49,7 +57,7 @@ export const instagramListCommentsTool: ToolConfig< request: { url: (params) => graphUrl(`/${params.mediaId.trim()}/comments`, { - fields: 'id,text,username,timestamp,like_count,hidden', + fields: 'id,text,from,timestamp,like_count,hidden', limit: String(clampGraphLimit(params.limit)), after: params.after?.trim() || undefined, }), @@ -66,21 +74,41 @@ export const instagramListCommentsTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson>>( + response, + 'Instagram comments response' + ) const items = Array.isArray(data.data) ? data.data : [] + const comments = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + + const from = + item.from && typeof item.from === 'object' + ? (item.from as { username?: unknown }) + : undefined + + return [ + { + id, + text: typeof item.text === 'string' ? item.text : null, + username: + typeof from?.username === 'string' + ? from.username + : typeof item.username === 'string' + ? item.username + : null, + timestamp: typeof item.timestamp === 'string' ? item.timestamp : null, + likeCount: typeof item.like_count === 'number' ? item.like_count : null, + hidden: typeof item.hidden === 'boolean' ? item.hidden : null, + }, + ] + }) 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. + comments, nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, }, } @@ -88,8 +116,9 @@ export const instagramListCommentsTool: ToolConfig< outputs: { comments: { - type: 'json', - description: 'Comments (id, text, username, timestamp, likeCount, hidden)', + type: 'array', + description: 'Comments on the media object', + items: { type: 'object', properties: INSTAGRAM_COMMENT_PROPERTIES }, }, 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 index de5e78f4291..f24260a5630 100644 --- a/apps/sim/tools/instagram/list_conversations.ts +++ b/apps/sim/tools/instagram/list_conversations.ts @@ -1,8 +1,16 @@ +import { INSTAGRAM_CONVERSATION_PROPERTIES } from '@/tools/instagram/output-properties' import type { InstagramListConversationsParams, InstagramListConversationsResponse, } from '@/tools/instagram/types' -import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramListConversationsTool: ToolConfig< @@ -71,17 +79,26 @@ export const instagramListConversationsTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson>>( + response, + 'Instagram conversations response' + ) const items = Array.isArray(data.data) ? data.data : [] + const conversations = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + return [ + { + id, + updatedTime: typeof item.updated_time === 'string' ? item.updated_time : null, + }, + ] + }) 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. + conversations, nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, }, } @@ -89,8 +106,9 @@ export const instagramListConversationsTool: ToolConfig< outputs: { conversations: { - type: 'json', - description: 'Conversations (id, updatedTime)', + type: 'array', + description: 'Instagram Direct conversations from this page', + items: { type: 'object', properties: INSTAGRAM_CONVERSATION_PROPERTIES }, }, 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 index 02860922163..d655c88b758 100644 --- a/apps/sim/tools/instagram/list_media.ts +++ b/apps/sim/tools/instagram/list_media.ts @@ -1,5 +1,13 @@ +import { INSTAGRAM_MEDIA_PROPERTIES } from '@/tools/instagram/output-properties' import type { InstagramListMediaParams, InstagramListMediaResponse } from '@/tools/instagram/types' -import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' const MEDIA_FIELDS = @@ -68,24 +76,35 @@ export const instagramListMediaTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson>>( + response, + 'Instagram media list response' + ) const items = Array.isArray(data.data) ? data.data : [] + const media = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + + return [ + { + id, + caption: typeof item.caption === 'string' ? item.caption : null, + mediaType: typeof item.media_type === 'string' ? item.media_type : null, + mediaProductType: + typeof item.media_product_type === 'string' ? item.media_product_type : null, + mediaUrl: typeof item.media_url === 'string' ? item.media_url : null, + permalink: typeof item.permalink === 'string' ? item.permalink : null, + timestamp: typeof item.timestamp === 'string' ? item.timestamp : null, + likeCount: typeof item.like_count === 'number' ? item.like_count : null, + commentsCount: typeof item.comments_count === 'number' ? item.comments_count : null, + }, + ] + }) 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. + media, nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, }, } @@ -93,9 +112,9 @@ export const instagramListMediaTool: ToolConfig< outputs: { media: { - type: 'json', - description: - 'List of media objects (id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount)', + type: 'array', + description: 'Media objects from this page', + items: { type: 'object', properties: INSTAGRAM_MEDIA_PROPERTIES }, }, nextCursor: { type: 'string', diff --git a/apps/sim/tools/instagram/list_stories.ts b/apps/sim/tools/instagram/list_stories.ts index d8a0f41ec95..f5b9fe4981e 100644 --- a/apps/sim/tools/instagram/list_stories.ts +++ b/apps/sim/tools/instagram/list_stories.ts @@ -1,8 +1,16 @@ +import { INSTAGRAM_STORY_PROPERTIES } from '@/tools/instagram/output-properties' import type { InstagramListStoriesParams, InstagramListStoriesResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramListStoriesTool: ToolConfig< @@ -32,6 +40,18 @@ export const instagramListStoriesTool: ToolConfig< 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 active stories to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous List Stories response', + }, }, request: { @@ -39,6 +59,8 @@ export const instagramListStoriesTool: ToolConfig< const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/stories` : '/me/stories' return graphUrl(path, { fields: 'id,media_type,media_url,timestamp', + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, }) }, method: 'GET', @@ -49,31 +71,45 @@ export const instagramListStoriesTool: ToolConfig< if (!response.ok) { return { success: false, - output: { stories: [] }, + output: { stories: [], nextCursor: null }, error: await readGraphError(response), } } - const data = await response.json() + const data = await readGraphJson>>( + response, + 'Instagram stories response' + ) const items = Array.isArray(data.data) ? data.data : [] + const stories = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + + return [ + { + id, + mediaType: typeof item.media_type === 'string' ? item.media_type : null, + mediaUrl: typeof item.media_url === 'string' ? item.media_url : null, + timestamp: typeof item.timestamp === 'string' ? item.timestamp : null, + }, + ] + }) 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, - })), + stories, + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, }, } }, outputs: { stories: { - type: 'json', - description: 'Active stories (id, mediaType, mediaUrl, timestamp)', + type: 'array', + description: 'Active stories from this page', + items: { type: 'object', properties: INSTAGRAM_STORY_PROPERTIES }, }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, }, } diff --git a/apps/sim/tools/instagram/output-properties.ts b/apps/sim/tools/instagram/output-properties.ts new file mode 100644 index 00000000000..94595426a1e --- /dev/null +++ b/apps/sim/tools/instagram/output-properties.ts @@ -0,0 +1,75 @@ +import type { OutputProperty } from '@/tools/types' + +export const INSTAGRAM_MEDIA_PROPERTIES = { + id: { type: 'string', description: 'Instagram media ID' }, + caption: { type: 'string', description: 'Caption text', nullable: true }, + mediaType: { type: 'string', description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM', nullable: true }, + mediaProductType: { + type: 'string', + description: 'Feed, Reels, or Stories product type', + nullable: true, + }, + mediaUrl: { + type: 'string', + description: 'Temporary Instagram CDN URL', + nullable: true, + }, + permalink: { type: 'string', description: 'Permalink to the media', nullable: true }, + timestamp: { type: 'string', description: 'ISO timestamp', nullable: true }, + likeCount: { type: 'number', description: 'Like count', nullable: true }, + commentsCount: { type: 'number', description: 'Comment count', nullable: true }, +} satisfies Record + +export const INSTAGRAM_STORY_PROPERTIES = { + id: { type: 'string', description: 'Instagram story ID' }, + mediaType: { type: 'string', description: 'IMAGE or VIDEO', nullable: true }, + mediaUrl: { + type: 'string', + description: 'Temporary Instagram CDN URL', + nullable: true, + }, + timestamp: { type: 'string', description: 'ISO timestamp', nullable: true }, +} satisfies Record + +export const INSTAGRAM_COMMENT_PROPERTIES = { + id: { type: 'string', description: 'Instagram comment ID' }, + text: { type: 'string', description: 'Comment text', nullable: true }, + username: { type: 'string', description: 'Comment author username', nullable: true }, + timestamp: { type: 'string', description: 'ISO timestamp', nullable: true }, + likeCount: { type: 'number', description: 'Like count', nullable: true }, + hidden: { type: 'boolean', description: 'Whether the comment is hidden', nullable: true }, +} satisfies Record + +export const INSTAGRAM_CONVERSATION_PROPERTIES = { + id: { type: 'string', description: 'Instagram conversation ID' }, + updatedTime: { type: 'string', description: 'Last updated timestamp', nullable: true }, +} satisfies Record + +export const INSTAGRAM_MESSAGE_REFERENCE_PROPERTIES = { + id: { type: 'string', description: 'Instagram message ID' }, + createdTime: { type: 'string', description: 'Created timestamp', nullable: true }, + isUnsupported: { + type: 'boolean', + description: 'Whether this message type is unsupported by the API', + }, +} satisfies Record + +export const INSTAGRAM_INSIGHT_PROPERTIES = { + name: { type: 'string', description: 'Metric name', nullable: true }, + period: { type: 'string', description: 'Aggregation period', nullable: true }, + title: { type: 'string', description: 'Human-readable metric title', nullable: true }, + description: { type: 'string', description: 'Metric description', nullable: true }, + values: { + type: 'json', + description: 'Metric values; shape varies by metric and requested breakdown', + }, + totalValue: { + type: 'json', + description: 'Aggregate metric value; shape varies by metric and breakdown', + nullable: true, + }, +} satisfies Record + +export const INSTAGRAM_CHILD_MEDIA_PROPERTIES = { + id: { type: 'string', description: 'Carousel child media ID' }, +} satisfies Record diff --git a/apps/sim/tools/instagram/private_reply.ts b/apps/sim/tools/instagram/private_reply.ts index 8e0a11d4bd8..43a27004db0 100644 --- a/apps/sim/tools/instagram/private_reply.ts +++ b/apps/sim/tools/instagram/private_reply.ts @@ -2,7 +2,13 @@ import type { InstagramPrivateReplyParams, InstagramPrivateReplyResponse, } from '@/tools/instagram/types' -import { graphUrl, idString, jsonBearerHeaders, readGraphError } from '@/tools/instagram/utils' +import { + graphUrl, + idString, + jsonBearerHeaders, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPrivateReplyTool: ToolConfig< @@ -11,7 +17,8 @@ export const instagramPrivateReplyTool: ToolConfig< > = { id: 'instagram_private_reply', name: 'Instagram Private Reply', - description: 'Send a private Direct message reply to a commenter (one per commenter)', + description: + 'Send the one allowed initial private reply within 7 days of a comment; follow-ups require a recipient response', version: '1.0.0', oauth: { @@ -68,25 +75,34 @@ export const instagramPrivateReplyTool: ToolConfig< } } - const data = (await response.json()) as { + const data = await readGraphJson<{ message_id?: string | number recipient_id?: string | number + }>(response, 'Instagram private reply response') + const messageId = idString(data.message_id) + const recipientId = idString(data.recipient_id) + if (!messageId || !recipientId) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: 'Instagram private reply response did not include message and recipient ids', + } } + return { success: true, output: { - messageId: idString(data.message_id), - recipientId: idString(data.recipient_id), + messageId, + recipientId, }, } }, outputs: { - messageId: { type: 'string', description: 'Sent message id', optional: true }, + messageId: { type: 'string', description: 'Sent message id' }, 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 ee245950e59..8aec385dc7e 100644 --- a/apps/sim/tools/instagram/publish_carousel.ts +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -1,8 +1,9 @@ -import type { - InstagramPublishCarouselParams, - InstagramPublishResponse, +import { + type InstagramPublishCarouselParams, + type InstagramPublishResponse, + PUBLISH_OUTPUTS, } from '@/tools/instagram/types' -import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import { createPublishTransform } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishCarouselTool: ToolConfig< diff --git a/apps/sim/tools/instagram/publish_image.ts b/apps/sim/tools/instagram/publish_image.ts index de299085171..08ce40a3568 100644 --- a/apps/sim/tools/instagram/publish_image.ts +++ b/apps/sim/tools/instagram/publish_image.ts @@ -1,5 +1,9 @@ -import type { InstagramPublishImageParams, InstagramPublishResponse } from '@/tools/instagram/types' -import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import { + type InstagramPublishImageParams, + type InstagramPublishResponse, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishImageTool: ToolConfig< diff --git a/apps/sim/tools/instagram/publish_reel.ts b/apps/sim/tools/instagram/publish_reel.ts index db492275999..b7b22c17bb7 100644 --- a/apps/sim/tools/instagram/publish_reel.ts +++ b/apps/sim/tools/instagram/publish_reel.ts @@ -1,5 +1,9 @@ -import type { InstagramPublishReelParams, InstagramPublishResponse } from '@/tools/instagram/types' -import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import { + type InstagramPublishReelParams, + type InstagramPublishResponse, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishReelTool: ToolConfig< diff --git a/apps/sim/tools/instagram/publish_story.ts b/apps/sim/tools/instagram/publish_story.ts index f1a4b1424ec..385445c0304 100644 --- a/apps/sim/tools/instagram/publish_story.ts +++ b/apps/sim/tools/instagram/publish_story.ts @@ -1,5 +1,9 @@ -import type { InstagramPublishResponse, InstagramPublishStoryParams } from '@/tools/instagram/types' -import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import { + type InstagramPublishResponse, + type InstagramPublishStoryParams, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishStoryTool: ToolConfig< @@ -8,7 +12,8 @@ export const instagramPublishStoryTool: ToolConfig< > = { id: 'instagram_publish_story', name: 'Instagram Publish Story', - description: 'Publish an image or video story from an uploaded file or public HTTPS URL', + description: + 'Publish an image or video story for an Instagram Business account from an uploaded file or public HTTPS URL', version: '1.0.0', oauth: { diff --git a/apps/sim/tools/instagram/publish_video.ts b/apps/sim/tools/instagram/publish_video.ts index 14d53157c6c..2763c0a9240 100644 --- a/apps/sim/tools/instagram/publish_video.ts +++ b/apps/sim/tools/instagram/publish_video.ts @@ -1,5 +1,9 @@ -import type { InstagramPublishResponse, InstagramPublishVideoParams } from '@/tools/instagram/types' -import { createPublishTransform, PUBLISH_OUTPUTS } from '@/tools/instagram/utils' +import { + type InstagramPublishResponse, + type InstagramPublishVideoParams, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramPublishVideoTool: ToolConfig< diff --git a/apps/sim/tools/instagram/reply_to_comment.ts b/apps/sim/tools/instagram/reply_to_comment.ts index 5f58091f417..fea5dc324a8 100644 --- a/apps/sim/tools/instagram/reply_to_comment.ts +++ b/apps/sim/tools/instagram/reply_to_comment.ts @@ -2,7 +2,13 @@ import type { InstagramReplyToCommentParams, InstagramReplyToCommentResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import { + graphUrl, + idString, + jsonBearerHeaders, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramReplyToCommentTool: ToolConfig< @@ -41,11 +47,10 @@ export const instagramReplyToCommentTool: ToolConfig< }, request: { - // Graph comment endpoints document message as a query/form parameter, not - // a JSON body (same style as hide_comment and set_comments_enabled). - url: (params) => graphUrl(`/${params.commentId.trim()}/replies`, { message: params.message }), + url: (params) => graphUrl(`/${params.commentId.trim()}/replies`), method: 'POST', - headers: (params) => bearerHeaders(params.accessToken), + headers: (params) => jsonBearerHeaders(params.accessToken), + body: (params) => ({ message: params.message }), }, transformResponse: async (response): Promise => { @@ -57,14 +62,26 @@ export const instagramReplyToCommentTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson<{ id?: string | number }>( + response, + 'Instagram reply comment response' + ) + const id = idString(data.id) + if (!id) { + return { + success: false, + output: { id: null }, + error: 'Instagram reply response did not include a comment id', + } + } + return { success: true, - output: { id: idString(data.id) }, + output: { id }, } }, outputs: { - id: { type: 'string', description: 'Created reply comment id', optional: true }, + id: { type: 'string', description: 'Created reply comment id' }, }, } diff --git a/apps/sim/tools/instagram/send_text_message.ts b/apps/sim/tools/instagram/send_text_message.ts index 647b42957d8..bf96edb451d 100644 --- a/apps/sim/tools/instagram/send_text_message.ts +++ b/apps/sim/tools/instagram/send_text_message.ts @@ -2,7 +2,13 @@ import type { InstagramSendTextMessageParams, InstagramSendTextMessageResponse, } from '@/tools/instagram/types' -import { graphUrl, idString, jsonBearerHeaders, readGraphError } from '@/tools/instagram/utils' +import { + graphUrl, + idString, + jsonBearerHeaders, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramSendTextMessageTool: ToolConfig< @@ -69,21 +75,31 @@ export const instagramSendTextMessageTool: ToolConfig< } } - const data = (await response.json()) as { + const data = await readGraphJson<{ message_id?: string | number recipient_id?: string | number + }>(response, 'Instagram send message response') + const messageId = idString(data.message_id) + const recipientId = idString(data.recipient_id) ?? params?.recipientId?.trim() ?? null + if (!messageId || !recipientId) { + return { + success: false, + output: { messageId: null, recipientId }, + error: 'Instagram send message response did not include the required ids', + } } + return { success: true, output: { - messageId: idString(data.message_id), - recipientId: idString(data.recipient_id) ?? params?.recipientId?.trim() ?? null, + messageId, + recipientId, }, } }, outputs: { - messageId: { type: 'string', description: 'Sent message id', optional: true }, - recipientId: { type: 'string', description: 'Recipient id', optional: true }, + messageId: { type: 'string', description: 'Sent message id' }, + recipientId: { type: 'string', description: 'Recipient id' }, }, } diff --git a/apps/sim/tools/instagram/set_comments_enabled.ts b/apps/sim/tools/instagram/set_comments_enabled.ts index 4cada4a260b..9ceb6a7d615 100644 --- a/apps/sim/tools/instagram/set_comments_enabled.ts +++ b/apps/sim/tools/instagram/set_comments_enabled.ts @@ -2,7 +2,7 @@ import type { InstagramSetCommentsEnabledParams, InstagramSetCommentsEnabledResponse, } from '@/tools/instagram/types' -import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import { bearerHeaders, graphUrl, readGraphError, readGraphJson } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' export const instagramSetCommentsEnabledTool: ToolConfig< @@ -56,10 +56,21 @@ export const instagramSetCommentsEnabledTool: ToolConfig< } } - const data = await response.json() + const data = await readGraphJson<{ success?: boolean }>( + response, + 'Instagram comment setting response' + ) + if (data.success !== true) { + return { + success: false, + output: { success: false }, + error: 'Instagram did not confirm that the comment setting was updated', + } + } + return { success: true, - output: { success: data.success === true || data.success === undefined }, + output: { success: true }, } }, diff --git a/apps/sim/tools/instagram/types.ts b/apps/sim/tools/instagram/types.ts index ad141d62882..c07d6d57d04 100644 --- a/apps/sim/tools/instagram/types.ts +++ b/apps/sim/tools/instagram/types.ts @@ -1,4 +1,13 @@ -import type { ToolResponse } from '@/tools/types' +import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' +import type { UserFile } from '@/executor/types' +import type { OutputProperty, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +/** Shared outputs returned by every Instagram publishing operation. */ +export const PUBLISH_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 }, +} satisfies Record export interface InstagramAccessParams { accessToken: string @@ -65,7 +74,26 @@ export interface InstagramGetMediaResponse extends ToolResponse { } } -export interface InstagramListStoriesParams extends InstagramAccessParams {} +export interface InstagramDownloadMediaParams { + accessToken: string + mediaId: string + filename?: string + _context?: WorkflowToolExecutionContext +} + +export interface InstagramDownloadMediaResponse extends ToolResponse { + output: { + files: UserFile[] + mediaId: string + mediaType: string | null + downloadedCount: number + } +} + +export interface InstagramListStoriesParams extends InstagramAccessParams { + limit?: number + after?: string +} export interface InstagramListStoriesResponse extends ToolResponse { output: { @@ -75,11 +103,12 @@ export interface InstagramListStoriesResponse extends ToolResponse { mediaUrl: string | null timestamp: string | null }> + nextCursor: string | null } } /** Uploaded UserFile object, or a public HTTPS URL string (advanced paste / legacy). */ -export type InstagramMediaInput = object | string +export type InstagramMediaInput = RawFileInput | string export interface InstagramPublishImageParams extends InstagramAccessParams { image: InstagramMediaInput @@ -108,7 +137,7 @@ export interface InstagramPublishStoryParams extends InstagramAccessParams { export interface InstagramPublishCarouselParams extends InstagramAccessParams { /** File array, single file, or comma-separated public URLs (prefix videos with video:). */ - media: InstagramMediaInput | InstagramMediaInput[] + media: RawFileInput | RawFileInput[] | string caption?: string } @@ -243,6 +272,8 @@ export interface InstagramListConversationsResponse extends ToolResponse { export interface InstagramGetConversationMessagesParams { accessToken: string conversationId: string + limit?: number + after?: string } export interface InstagramGetConversationMessagesResponse extends ToolResponse { @@ -251,7 +282,9 @@ export interface InstagramGetConversationMessagesResponse extends ToolResponse { messages: Array<{ id: string createdTime: string | null + isUnsupported: boolean }> + nextCursor: string | null } } @@ -329,6 +362,7 @@ export type InstagramResponse = | InstagramGetProfileResponse | InstagramListMediaResponse | InstagramGetMediaResponse + | InstagramDownloadMediaResponse | InstagramListStoriesResponse | InstagramPublishResponse | InstagramGetContainerStatusResponse diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index 065359f7e93..3d62e305bb8 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -1,7 +1,36 @@ +import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants' import type { InstagramPublishResponse } from '@/tools/instagram/types' -import type { ToolConfig } from '@/tools/types' + +export const INSTAGRAM_RESPONSE_MAX_BYTES = 2 * 1024 * 1024 + +export interface InstagramGraphPaging { + cursors?: { after?: string } + next?: string +} + +export interface InstagramGraphPage { + data?: T[] + paging?: InstagramGraphPaging +} + +export async function readGraphJson( + response: Response, + label: string, + signal?: AbortSignal +): Promise { + return readResponseJsonWithLimit(response, { + maxBytes: INSTAGRAM_RESPONSE_MAX_BYTES, + label, + signal, + }) +} export function bearerHeaders(accessToken: string): Record { return { @@ -42,36 +71,66 @@ export function idString(value: unknown): string | null { return String(value) } +interface InstagramGraphErrorBody { + error?: { + message?: string + type?: string + code?: number + error_subcode?: number + fbtrace_id?: string + } +} + export async function readGraphError(response: Response): Promise { - const text = await response.text() + const text = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram Graph error response', + }).catch(() => '') + try { - const json = JSON.parse(text) as { error?: { message?: string; code?: number } } - if (json.error?.message) { - return json.error.message + const graphError = (JSON.parse(text) as InstagramGraphErrorBody).error + if (graphError?.message) { + const diagnostics = [ + graphError.type ? `type ${graphError.type}` : null, + graphError.code !== undefined ? `code ${graphError.code}` : null, + graphError.error_subcode !== undefined ? `subcode ${graphError.error_subcode}` : null, + graphError.fbtrace_id ? `trace ${graphError.fbtrace_id}` : null, + ].filter((value): value is string => value !== null) + + return diagnostics.length > 0 + ? `${graphError.message} (${diagnostics.join(', ')})` + : graphError.message } } catch { - // keep raw text + return text || response.statusText } + return text || response.statusText } -export async function resolveIgUserId(accessToken: string, igUserId?: string): Promise { +export async function resolveIgUserId( + accessToken: string, + igUserId?: string, + signal?: AbortSignal +): Promise { if (igUserId && igUserId.trim().length > 0) { return igUserId.trim() } const response = await fetch(graphUrl('/me', { fields: 'user_id' }), { headers: bearerHeaders(accessToken), + signal, }) if (!response.ok) { throw new Error(`Failed to resolve Instagram user id: ${await readGraphError(response)}`) } - // Only /me's user_id is the Instagram professional account ID that publish - // and messaging paths expect; /me's id is an app-scoped ID from a different - // ID space, so never fall back to it. - const data = (await response.json()) as { user_id?: string | number } + const data = await readGraphJson<{ user_id?: string | number }>( + response, + 'Instagram user response', + signal + ) if (data.user_id == null || data.user_id === '') { throw new Error('Instagram /me response did not include a user_id') } @@ -82,35 +141,63 @@ export type ContainerStatusCode = 'EXPIRED' | 'ERROR' | 'FINISHED' | 'IN_PROGRES export async function getContainerStatus( accessToken: string, - containerId: string + containerId: string, + signal?: AbortSignal ): Promise<{ statusCode: ContainerStatusCode | null; status: string | null }> { const response = await fetch(graphUrl(`/${containerId}`, { fields: 'status_code,status' }), { headers: bearerHeaders(accessToken), + signal, }) 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 } + const data = await readGraphJson<{ status_code?: string; status?: string }>( + response, + 'Instagram container status response', + signal + ) return { statusCode: (data.status_code as ContainerStatusCode | undefined) ?? null, status: data.status ?? null, } } -// Meta recommends checking once per minute for no more than five minutes. -// Six attempts = an immediate check plus five minute-spaced checks, so the -// full five-minute window is covered before timing out. const POLL_INTERVAL_MS = 60_000 const POLL_MAX_ATTEMPTS = 6 +async function waitForNextPoll(signal?: AbortSignal): Promise { + if (!signal) { + await sleep(POLL_INTERVAL_MS) + return + } + + if (signal.aborted) { + throw toError(signal.reason ?? new Error('Instagram publishing was cancelled')) + } + + let abortHandler: (() => void) | undefined + const aborted = new Promise((_, reject) => { + abortHandler = () => + reject(toError(signal.reason ?? new Error('Instagram publishing was cancelled'))) + signal.addEventListener('abort', abortHandler, { once: true }) + }) + + try { + await Promise.race([sleep(POLL_INTERVAL_MS), aborted]) + } finally { + if (abortHandler) signal.removeEventListener('abort', abortHandler) + } +} + export async function waitForContainerReady( accessToken: string, - containerId: string + containerId: string, + signal?: AbortSignal ): Promise<{ statusCode: ContainerStatusCode; status: string | null }> { for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { - const { statusCode, status } = await getContainerStatus(accessToken, containerId) + const { statusCode, status } = await getContainerStatus(accessToken, containerId, signal) if (statusCode === 'FINISHED' || statusCode === 'PUBLISHED') { return { statusCode, status } @@ -122,7 +209,7 @@ export async function waitForContainerReady( } if (attempt < POLL_MAX_ATTEMPTS - 1) { - await sleep(POLL_INTERVAL_MS) + await waitForNextPoll(signal) } } @@ -137,7 +224,8 @@ export async function waitForContainerReady( async function postGraphForm( accessToken: string, path: string, - params: Record + params: Record, + signal?: AbortSignal ): Promise { const form = new URLSearchParams() for (const [key, value] of Object.entries(params)) { @@ -153,21 +241,27 @@ async function postGraphForm( 'Content-Type': 'application/x-www-form-urlencoded', }, body: form.toString(), + signal, }) } export async function createMediaContainer( accessToken: string, igUserId: string, - body: Record + body: Record, + signal?: AbortSignal ): Promise { - const response = await postGraphForm(accessToken, `/${igUserId}/media`, body) + const response = await postGraphForm(accessToken, `/${igUserId}/media`, body, signal) if (!response.ok) { throw new Error(`Failed to create media container: ${await readGraphError(response)}`) } - const data = (await response.json()) as { id?: string | number } + const data = await readGraphJson<{ id?: string | number }>( + response, + 'Instagram create container response', + signal + ) const id = idString(data.id) if (!id) { throw new Error('Create media container response missing id') @@ -178,17 +272,27 @@ export async function createMediaContainer( export async function publishMediaContainer( accessToken: string, igUserId: string, - creationId: string + creationId: string, + signal?: AbortSignal ): Promise { - const response = await postGraphForm(accessToken, `/${igUserId}/media_publish`, { - creation_id: creationId, - }) + const response = await postGraphForm( + accessToken, + `/${igUserId}/media_publish`, + { + creation_id: creationId, + }, + signal + ) if (!response.ok) { throw new Error(`Failed to publish media: ${await readGraphError(response)}`) } - const data = (await response.json()) as { id?: string | number } + const data = await readGraphJson<{ id?: string | number }>( + response, + 'Instagram publish media response', + signal + ) const id = idString(data.id) if (!id) { throw new Error('Publish media response missing id') @@ -196,21 +300,44 @@ export async function publishMediaContainer( return id } -/** Shared output schema for the five publish tools (image, video, reel, story, carousel). */ -export const PUBLISH_OUTPUTS: ToolConfig['outputs'] = { - containerId: { type: 'string', description: 'Media container id', optional: true }, - mediaId: { type: 'string', description: 'Published media id', optional: true }, - statusCode: { type: 'string', description: 'Final container status', optional: true }, -} - /** * Shared transformResponse for the publish tools, which all proxy through * internal API routes returning `{ success, output, error }`. */ export function createPublishTransform(fallbackError: string) { return async (response: Response): Promise => { - const data = await response.json() - const output = data.output || { containerId: null, mediaId: null, statusCode: null } + const text = await readResponseTextWithLimit(response, { + maxBytes: INSTAGRAM_RESPONSE_MAX_BYTES, + label: 'Instagram publish response', + }).catch(() => '') + const fallbackOutput = { containerId: null, mediaId: null, statusCode: null } + + let data: { + success?: boolean + output?: InstagramPublishResponse['output'] + error?: string + } = {} + + if (text) { + try { + data = JSON.parse(text) as typeof data + } catch { + if (!response.ok) { + return { + success: false, + output: fallbackOutput, + error: `${fallbackError}: ${text}`, + } + } + return { + success: false, + output: fallbackOutput, + error: `${fallbackError}: invalid JSON response`, + } + } + } + + const output = data.output ?? fallbackOutput if (!response.ok || data.success === false) { return { success: false, output, error: data.error || fallbackError } } diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 67632830151..4d84db3425e 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1859,6 +1859,7 @@ import { } from '@/tools/infisical' import { instagramDeleteCommentTool, + instagramDownloadMediaTool, instagramGetAccountInsightsTool, instagramGetContainerStatusTool, instagramGetConversationMessagesTool, @@ -4925,6 +4926,7 @@ export const tools: Record = { hex_update_group: hexUpdateGroupTool, hex_update_project: hexUpdateProjectTool, instagram_delete_comment: instagramDeleteCommentTool, + instagram_download_media: instagramDownloadMediaTool, instagram_get_account_insights: instagramGetAccountInsightsTool, instagram_get_container_status: instagramGetContainerStatusTool, instagram_get_conversation_messages: instagramGetConversationMessagesTool, diff --git a/apps/sim/tools/tiktok/api-schemas.test.ts b/apps/sim/tools/tiktok/api-schemas.test.ts new file mode 100644 index 00000000000..4509e10e3e5 --- /dev/null +++ b/apps/sim/tools/tiktok/api-schemas.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + tiktokCreatorInfoApiDataSchema, + tiktokGetUserApiDataSchema, + tiktokListVideosApiDataSchema, + tiktokPostStatusApiDataSchema, + tiktokPublishInitApiDataSchema, + tiktokQueryVideosApiDataSchema, +} from '@/tools/tiktok/api-schemas' + +describe('TikTok documented response schemas', () => { + it('requires stable user identity fields', () => { + expect( + tiktokGetUserApiDataSchema.safeParse({ + user: { open_id: 'user-1', display_name: 'Creator' }, + }).success + ).toBe(true) + expect( + tiktokGetUserApiDataSchema.safeParse({ + user: { display_name: 'Creator' }, + }).success + ).toBe(false) + }) + + it('requires pagination only on list-video payloads and always requires video IDs', () => { + const videos = [{ id: 'video-1' }] + expect( + tiktokListVideosApiDataSchema.safeParse({ videos, cursor: 123, has_more: false }).success + ).toBe(true) + expect(tiktokListVideosApiDataSchema.safeParse({ videos }).success).toBe(false) + expect(tiktokQueryVideosApiDataSchema.safeParse({ videos }).success).toBe(true) + expect(tiktokQueryVideosApiDataSchema.safeParse({ videos: [{}] }).success).toBe(false) + }) + + it('requires the documented creator capability fields', () => { + const complete = { + creator_avatar_url: 'https://example.com/avatar', + creator_username: 'creator', + creator_nickname: 'Creator', + privacy_level_options: ['SELF_ONLY'], + comment_disabled: false, + duet_disabled: false, + stitch_disabled: false, + max_video_post_duration_sec: 180, + } + + expect(tiktokCreatorInfoApiDataSchema.safeParse(complete).success).toBe(true) + const { privacy_level_options: _privacyLevelOptions, ...incomplete } = complete + expect(tiktokCreatorInfoApiDataSchema.safeParse(incomplete).success).toBe(false) + }) + + it('requires publish initialization identifiers and post status', () => { + expect( + tiktokPublishInitApiDataSchema.safeParse({ + publish_id: 'publish-1', + upload_url: 'https://upload.example/video', + }).success + ).toBe(true) + expect(tiktokPublishInitApiDataSchema.safeParse({ publish_id: 'publish-1' }).success).toBe( + false + ) + expect(tiktokPostStatusApiDataSchema.safeParse({ status: 'PROCESSING_UPLOAD' }).success).toBe( + true + ) + expect(tiktokPostStatusApiDataSchema.safeParse({}).success).toBe(false) + }) +}) diff --git a/apps/sim/tools/tiktok/api-schemas.ts b/apps/sim/tools/tiktok/api-schemas.ts index 82d1606c0f0..1e244a98f5e 100644 --- a/apps/sim/tools/tiktok/api-schemas.ts +++ b/apps/sim/tools/tiktok/api-schemas.ts @@ -2,11 +2,11 @@ import { z } from 'zod' /** Raw user object returned by TikTok's user info API. */ export const tiktokApiUserSchema = z.object({ - open_id: z.string().optional(), + open_id: z.string(), union_id: z.string().optional(), avatar_url: z.string().optional(), avatar_large_url: z.string().optional(), - display_name: z.string().optional(), + display_name: z.string(), bio_description: z.string().optional(), profile_deep_link: z.string().optional(), is_verified: z.boolean().optional(), @@ -19,12 +19,12 @@ export const tiktokApiUserSchema = z.object({ /** Data payload returned by TikTok's user info API. */ export const tiktokGetUserApiDataSchema = z.object({ - user: tiktokApiUserSchema.optional(), + user: tiktokApiUserSchema, }) /** Raw video object returned by TikTok's video APIs. */ export const tiktokApiVideoSchema = z.object({ - id: z.string().optional(), + id: z.string(), title: z.string().optional(), cover_image_url: z.string().optional(), embed_link: z.string().optional(), @@ -41,34 +41,39 @@ export const tiktokApiVideoSchema = z.object({ share_count: z.number().optional(), }) -/** Data payload returned by TikTok's list/query video APIs. */ -export const tiktokVideosApiDataSchema = z.object({ - videos: z.array(tiktokApiVideoSchema).optional(), - cursor: z.number().optional(), - has_more: z.boolean().optional(), +/** Data payload returned by TikTok's paginated video list API. */ +export const tiktokListVideosApiDataSchema = z.object({ + videos: z.array(tiktokApiVideoSchema), + cursor: z.number(), + has_more: z.boolean(), +}) + +/** Data payload returned by TikTok's video query API. */ +export const tiktokQueryVideosApiDataSchema = z.object({ + videos: z.array(tiktokApiVideoSchema), }) /** Data payload returned by TikTok's creator info API. */ export const tiktokCreatorInfoApiDataSchema = z.object({ - creator_avatar_url: z.string().optional(), - creator_username: z.string().optional(), - creator_nickname: z.string().optional(), - privacy_level_options: z.array(z.string()).optional(), - comment_disabled: z.boolean().optional(), - duet_disabled: z.boolean().optional(), - stitch_disabled: z.boolean().optional(), - max_video_post_duration_sec: z.number().optional(), + creator_avatar_url: z.string(), + creator_username: z.string(), + creator_nickname: z.string(), + privacy_level_options: z.array(z.string()), + comment_disabled: z.boolean(), + duet_disabled: z.boolean(), + stitch_disabled: z.boolean(), + max_video_post_duration_sec: z.number(), }) /** Data payload returned by TikTok's publish initialization APIs. */ export const tiktokPublishInitApiDataSchema = z.object({ - publish_id: z.string().optional(), - upload_url: z.string().optional(), + publish_id: z.string(), + upload_url: z.string(), }) /** Data payload returned by TikTok's post status API. */ export const tiktokPostStatusApiDataSchema = z.object({ - status: z.string().optional(), + status: z.string(), fail_reason: z.string().optional(), uploaded_bytes: z.number().optional(), downloaded_bytes: z.number().optional(), diff --git a/apps/sim/tools/tiktok/direct_post_video.test.ts b/apps/sim/tools/tiktok/direct_post_video.test.ts new file mode 100644 index 00000000000..38130270ba7 --- /dev/null +++ b/apps/sim/tools/tiktok/direct_post_video.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { tiktokDirectPostVideoTool } from '@/tools/tiktok/direct_post_video' +import type { TikTokDirectPostVideoParams } from '@/tools/tiktok/types' + +describe('tiktokDirectPostVideoTool', () => { + it('fails closed until one-use human approval is supported', () => { + const params = { + accessToken: 'token', + brandContentToggle: false, + disableComment: false, + disableDuet: false, + disableStitch: false, + file: { + id: 'file-1', + key: 'workspace/workspace-1/file-1', + name: 'video.mp4', + size: 1024, + type: 'video/mp4', + url: '/api/files/serve?key=workspace%2Fworkspace-1%2Ffile-1', + }, + musicUsageConsent: 'accepted', + privacyLevel: 'SELF_ONLY', + } satisfies TikTokDirectPostVideoParams + + expect(() => tiktokDirectPostVideoTool.request.body?.(params)).toThrow( + 'TikTok Direct Post is unavailable' + ) + }) +}) diff --git a/apps/sim/tools/tiktok/direct_post_video.ts b/apps/sim/tools/tiktok/direct_post_video.ts index dccb1cdde50..7522f0662d1 100644 --- a/apps/sim/tools/tiktok/direct_post_video.ts +++ b/apps/sim/tools/tiktok/direct_post_video.ts @@ -5,23 +5,8 @@ import type { import { readTikTokPublishInitResponse, toTikTokPublishToolResponse } from '@/tools/tiktok/utils' import type { ToolConfig } from '@/tools/types' -function buildPostInfo(params: TikTokDirectPostVideoParams): Record { - const postInfo: Record = { - brand_content_toggle: params.brandContentToggle, - brand_organic_toggle: params.brandOrganicToggle ?? false, - privacy_level: params.privacyLevel, - disable_duet: params.disableDuet, - disable_stitch: params.disableStitch, - disable_comment: params.disableComment, - } - - if (params.title) postInfo.title = params.title - if (params.videoCoverTimestampMs !== undefined) { - postInfo.video_cover_timestamp_ms = params.videoCoverTimestampMs - } - if (params.isAigc !== undefined) postInfo.is_aigc = params.isAigc - return postInfo -} +const DIRECT_POST_UNAVAILABLE_MESSAGE = + 'TikTok Direct Post is unavailable until Sim can bind TikTok-required human review and consent to one exact upload. Use Upload Video Draft for review and publishing in TikTok.' export const tiktokDirectPostVideoTool: ToolConfig< TikTokDirectPostVideoParams, @@ -30,7 +15,7 @@ export const tiktokDirectPostVideoTool: ToolConfig< id: 'tiktok_direct_post_video', name: 'TikTok Direct Post Video', description: - 'Publish a video to TikTok by uploading a file from the workflow. Rate limit: 6 requests per minute per user.', + 'Unavailable compatibility tool. Use Upload Video Draft so the account owner can review and publish in TikTok.', version: '1.0.0', oauth: { @@ -123,16 +108,8 @@ export const tiktokDirectPostVideoTool: ToolConfig< headers: () => ({ 'Content-Type': 'application/json', }), - body: (params: TikTokDirectPostVideoParams) => { - const postInfo = buildPostInfo(params) - - return { - accessToken: params.accessToken, - mode: 'direct', - file: params.file, - postInfo, - musicUsageConsent: params.musicUsageConsent, - } + body: () => { + throw new Error(DIRECT_POST_UNAVAILABLE_MESSAGE) }, }, diff --git a/apps/sim/tools/tiktok/get_post_status.test.ts b/apps/sim/tools/tiktok/get_post_status.test.ts new file mode 100644 index 00000000000..960a6e64d67 --- /dev/null +++ b/apps/sim/tools/tiktok/get_post_status.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { tiktokGetPostStatusTool } from '@/tools/tiktok/get_post_status' + +describe('TikTok Get Post Status OAuth scopes', () => { + it("does not model TikTok's publish-or-upload authorization as all-of scopes", () => { + expect(tiktokGetPostStatusTool.oauth).toEqual({ + required: true, + provider: 'tiktok', + }) + }) +}) diff --git a/apps/sim/tools/tiktok/get_post_status.ts b/apps/sim/tools/tiktok/get_post_status.ts index 5e18f2f3b6e..99ff8ee84c8 100644 --- a/apps/sim/tools/tiktok/get_post_status.ts +++ b/apps/sim/tools/tiktok/get_post_status.ts @@ -26,7 +26,6 @@ export const tiktokGetPostStatusTool: ToolConfig< oauth: { required: true, provider: 'tiktok', - requiredScopes: ['video.publish', 'video.upload'], }, params: { diff --git a/apps/sim/tools/tiktok/get_user.test.ts b/apps/sim/tools/tiktok/get_user.test.ts new file mode 100644 index 00000000000..74c1cf3882b --- /dev/null +++ b/apps/sim/tools/tiktok/get_user.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { tiktokGetUserTool } from '@/tools/tiktok/get_user' +import { TIKTOK_USER_FIELDS } from '@/tools/tiktok/utils' + +function getRequestUrl(fields?: string): string { + const url = tiktokGetUserTool.request.url + if (typeof url !== 'function') throw new Error('Expected a dynamic TikTok user URL') + return url({ accessToken: 'token', fields }) +} + +describe('TikTok Get User fields', () => { + it('always requests stable identity fields and de-duplicates custom fields', () => { + const url = new URL(getRequestUrl('avatar_large_url,open_id,avatar_large_url')) + + expect(url.searchParams.get('fields')).toBe('open_id,display_name,avatar_large_url') + }) + + it('uses the canonical field set when the custom value is blank', () => { + const url = new URL(getRequestUrl(' ')) + + expect(url.searchParams.get('fields')).toBe(TIKTOK_USER_FIELDS) + }) + + it('rejects unsupported custom fields deterministically', () => { + expect(() => getRequestUrl('open_id,not_a_field,also_bad,not_a_field')).toThrow( + 'Unsupported TikTok user field(s): not_a_field, also_bad' + ) + }) +}) diff --git a/apps/sim/tools/tiktok/get_user.ts b/apps/sim/tools/tiktok/get_user.ts index 384196a152d..901d1923a49 100644 --- a/apps/sim/tools/tiktok/get_user.ts +++ b/apps/sim/tools/tiktok/get_user.ts @@ -1,8 +1,30 @@ import { tiktokGetUserApiDataSchema } from '@/tools/tiktok/api-schemas' import type { TikTokGetUserParams, TikTokGetUserResponse } from '@/tools/tiktok/types' -import { readTikTokApiResponse, TIKTOK_USER_FIELDS } from '@/tools/tiktok/utils' +import { + readTikTokApiResponse, + TIKTOK_USER_FIELD_NAMES, + TIKTOK_USER_FIELDS, +} from '@/tools/tiktok/utils' import type { ToolConfig } from '@/tools/types' +const REQUIRED_USER_FIELDS = ['open_id', 'display_name'] as const +const USER_FIELD_ALLOWLIST = new Set(TIKTOK_USER_FIELD_NAMES) + +function resolveUserFields(fields: string | undefined): string { + if (!fields?.trim()) return TIKTOK_USER_FIELDS + + const requested = fields + .split(',') + .map((field) => field.trim()) + .filter(Boolean) + const invalid = requested.filter((field) => !USER_FIELD_ALLOWLIST.has(field)) + if (invalid.length > 0) { + throw new Error(`Unsupported TikTok user field(s): ${[...new Set(invalid)].join(', ')}`) + } + + return [...new Set([...REQUIRED_USER_FIELDS, ...requested])].join(',') +} + function emptyUserOutput(): TikTokGetUserResponse['output'] { return { openId: '', @@ -45,13 +67,13 @@ export const tiktokGetUserTool: ToolConfig { - const fields = params.fields || TIKTOK_USER_FIELDS + const fields = resolveUserFields(params.fields) return `https://open.tiktokapis.com/v2/user/info/?fields=${encodeURIComponent(fields)}` }, method: 'GET', diff --git a/apps/sim/tools/tiktok/list_videos.ts b/apps/sim/tools/tiktok/list_videos.ts index 14ba8ed8ecf..260a591f258 100644 --- a/apps/sim/tools/tiktok/list_videos.ts +++ b/apps/sim/tools/tiktok/list_videos.ts @@ -1,4 +1,4 @@ -import { tiktokVideosApiDataSchema } from '@/tools/tiktok/api-schemas' +import { tiktokListVideosApiDataSchema } from '@/tools/tiktok/api-schemas' import { TIKTOK_VIDEO_OUTPUT_PROPERTIES, type TikTokListVideosParams, @@ -66,7 +66,7 @@ export const tiktokListVideosTool: ToolConfig => { - const { data, error } = await readTikTokApiResponse(response, tiktokVideosApiDataSchema) + const { data, error } = await readTikTokApiResponse(response, tiktokListVideosApiDataSchema) if (error) { return { @@ -80,14 +80,26 @@ export const tiktokListVideosTool: ToolConfig { + it('exposes the temporary avatar as a composable file output', async () => { + const response = Response.json({ + data: { + creator_avatar_url: 'https://p16-sign.tiktokcdn-us.com/avatar.jpeg', + creator_username: 'creator', + creator_nickname: 'Creator', + privacy_level_options: ['SELF_ONLY'], + comment_disabled: true, + duet_disabled: false, + stitch_disabled: true, + max_video_post_duration_sec: 180, + }, + error: { code: 'ok' }, + }) + + await expect(tiktokQueryCreatorInfoTool.transformResponse?.(response)).resolves.toMatchObject({ + success: true, + output: { + creatorAvatarUrl: 'https://p16-sign.tiktokcdn-us.com/avatar.jpeg', + creatorAvatarFile: { + name: 'creator-avatar.jpg', + mimeType: 'image/jpeg', + url: 'https://p16-sign.tiktokcdn-us.com/avatar.jpeg', + }, + privacyLevelOptions: ['SELF_ONLY'], + }, + }) + expect(tiktokQueryCreatorInfoTool.outputs?.creatorAvatarFile).toMatchObject({ + type: 'file', + optional: true, + }) + expect(tiktokQueryCreatorInfoTool.outputs?.creatorAvatarUrl?.description).toMatch(/two hours/i) + }) + + it('omits the file descriptor when TikTok returns an empty avatar URL', async () => { + const response = Response.json({ + data: { + creator_avatar_url: '', + creator_username: 'creator', + creator_nickname: 'Creator', + privacy_level_options: ['SELF_ONLY'], + comment_disabled: false, + duet_disabled: false, + stitch_disabled: false, + max_video_post_duration_sec: 180, + }, + error: { code: 'ok' }, + }) + + const result = await tiktokQueryCreatorInfoTool.transformResponse?.(response) + + expect(result?.success).toBe(true) + expect(result?.output.creatorAvatarUrl).toBe('') + expect(result?.output).not.toHaveProperty('creatorAvatarFile') + expect(tiktokQueryCreatorInfoTool.outputs?.privacyLevelOptions?.items).toEqual({ + type: 'string', + description: 'Privacy level currently available to the authenticated creator', + }) + }) +}) diff --git a/apps/sim/tools/tiktok/query_creator_info.ts b/apps/sim/tools/tiktok/query_creator_info.ts index f39f767daa4..2b969b9aa1e 100644 --- a/apps/sim/tools/tiktok/query_creator_info.ts +++ b/apps/sim/tools/tiktok/query_creator_info.ts @@ -26,7 +26,7 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< id: 'tiktok_query_creator_info', name: 'TikTok Query Creator Info', description: - 'Check if the authenticated TikTok user can post content and retrieve their available privacy options, interaction settings, and maximum video duration. Required before any post per TikTok UX guidelines.', + "Inspect the authenticated creator's Direct Post capabilities, privacy options, interaction settings, and maximum video duration. Direct Post is currently unavailable in Sim; Upload Video Draft does not require this step.", version: '1.0.0', oauth: { @@ -80,6 +80,13 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< success: true, output: { creatorAvatarUrl: creatorInfo.creator_avatar_url ?? null, + ...(creatorInfo.creator_avatar_url && { + creatorAvatarFile: { + name: `${creatorInfo.creator_username || 'tiktok-creator'}-avatar.jpg`, + mimeType: 'image/jpeg', + url: creatorInfo.creator_avatar_url, + }, + }), creatorUsername: creatorInfo.creator_username ?? null, creatorNickname: creatorInfo.creator_nickname ?? null, privacyLevelOptions: creatorInfo.privacy_level_options ?? [], @@ -94,7 +101,14 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< outputs: { creatorAvatarUrl: { type: 'string', - description: 'URL of the creator avatar', + description: + 'Temporary URL of the current creator avatar. TikTok documents this URL as expiring two hours after it is returned.', + optional: true, + }, + creatorAvatarFile: { + type: 'file', + description: + 'Durable workflow-file copy of the current creator avatar, suitable for chaining into file-consuming blocks such as email attachments.', optional: true, }, creatorUsername: { @@ -111,6 +125,10 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< type: 'array', description: 'Available privacy levels for posting (e.g., PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)', + items: { + type: 'string', + description: 'Privacy level currently available to the authenticated creator', + }, }, commentDisabled: { type: 'boolean', diff --git a/apps/sim/tools/tiktok/query_videos.ts b/apps/sim/tools/tiktok/query_videos.ts index b9e89c2a64f..636f515272b 100644 --- a/apps/sim/tools/tiktok/query_videos.ts +++ b/apps/sim/tools/tiktok/query_videos.ts @@ -1,4 +1,4 @@ -import { tiktokVideosApiDataSchema } from '@/tools/tiktok/api-schemas' +import { tiktokQueryVideosApiDataSchema } from '@/tools/tiktok/api-schemas' import { TIKTOK_VIDEO_OUTPUT_PROPERTIES, type TikTokQueryVideosParams, @@ -65,7 +65,7 @@ export const tiktokQueryVideosTool: ToolConfig => { - const { data, error } = await readTikTokApiResponse(response, tiktokVideosApiDataSchema) + const { data, error } = await readTikTokApiResponse(response, tiktokQueryVideosApiDataSchema) if (error) { return { @@ -77,7 +77,17 @@ export const tiktokQueryVideosTool: ToolConfig { @@ -53,9 +54,22 @@ describe('TikTok tool utilities', () => { }) }) + it('rejects oversized TikTok JSON responses without materializing them unboundedly', async () => { + const response = new Response('x'.repeat(TIKTOK_API_RESPONSE_MAX_BYTES + 1)) + + await expect(readTikTokApiResponse(response, tiktokPublishInitApiDataSchema)).resolves.toEqual({ + data: null, + error: { + code: 'invalid_response', + message: 'TikTok response exceeded the maximum supported size or could not be read', + }, + rawBody: '', + }) + }) + it('normalizes direct TikTok publish responses', async () => { const response = Response.json({ - data: { publish_id: 'publish-1' }, + data: { publish_id: 'publish-1', upload_url: 'https://upload.example/video' }, error: { code: 'ok' }, }) diff --git a/apps/sim/tools/tiktok/utils.ts b/apps/sim/tools/tiktok/utils.ts index 012a40fd641..56617c3435e 100644 --- a/apps/sim/tools/tiktok/utils.ts +++ b/apps/sim/tools/tiktok/utils.ts @@ -1,15 +1,33 @@ import { truncate } from '@sim/utils/string' import type { ZodType } from 'zod' +import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import { type TikTokApiVideo, tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas' import type { TikTokApiError, TikTokPublishResponse, TikTokVideo } from '@/tools/tiktok/types' +export const TIKTOK_API_RESPONSE_MAX_BYTES = 1024 * 1024 + /** * Default fields requested from TikTok's `/v2/user/info/` endpoint, covering the * `user.info.basic`, `user.info.profile`, and `user.info.stats` scopes. * `avatar_url` and `avatar_large_url` feed the file-typed `avatarFile` output. */ -export const TIKTOK_USER_FIELDS = - 'open_id,union_id,avatar_url,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count' +export const TIKTOK_USER_FIELD_NAMES = [ + 'open_id', + 'union_id', + 'avatar_url', + 'avatar_large_url', + 'display_name', + 'bio_description', + 'profile_deep_link', + 'is_verified', + 'username', + 'follower_count', + 'following_count', + 'likes_count', + 'video_count', +] as const + +export const TIKTOK_USER_FIELDS = TIKTOK_USER_FIELD_NAMES.join(',') /** * Fields requested from TikTok's `/v2/video/list/` and `/v2/video/query/` endpoints. @@ -56,6 +74,10 @@ interface TikTokPublishInitResult { error?: string } +interface ReadTikTokApiResponseOptions { + signal?: AbortSignal +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } @@ -87,8 +109,28 @@ function httpError(response: Response, rawBody: string, message?: string): TikTo } } -async function readJsonObject(response: Response): Promise { - const rawBody = await response.text() +async function readJsonObject( + response: Response, + options: ReadTikTokApiResponseOptions = {} +): Promise { + let rawBody: string + try { + rawBody = await readResponseTextWithLimit(response, { + maxBytes: TIKTOK_API_RESPONSE_MAX_BYTES, + label: 'TikTok API response', + signal: options.signal, + }) + } catch { + options.signal?.throwIfAborted() + return { + body: null, + error: { + code: 'invalid_response', + message: 'TikTok response exceeded the maximum supported size or could not be read', + }, + rawBody: '', + } + } let parsed: unknown try { @@ -159,9 +201,10 @@ function parseApiEnvelope( /** Reads and normalizes a typed TikTok API envelope. */ export async function readTikTokApiResponse( response: Response, - dataSchema: ZodType + dataSchema: ZodType, + options: ReadTikTokApiResponseOptions = {} ): Promise> { - return parseApiEnvelope(response, await readJsonObject(response), dataSchema) + return parseApiEnvelope(response, await readJsonObject(response, options), dataSchema) } /** Enforces TikTok's bounded array request limits before making a network request. */ diff --git a/apps/sim/tools/tiktok/videos.test.ts b/apps/sim/tools/tiktok/videos.test.ts new file mode 100644 index 00000000000..4ddab7ee9fc --- /dev/null +++ b/apps/sim/tools/tiktok/videos.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { tiktokListVideosTool } from '@/tools/tiktok/list_videos' +import { tiktokQueryVideosTool } from '@/tools/tiktok/query_videos' + +describe('TikTok video response boundaries', () => { + it('requires list pagination while accepting query responses without it', async () => { + const listResult = await tiktokListVideosTool.transformResponse?.( + Response.json({ + data: { videos: [{ id: 'video-1' }], cursor: 123, has_more: false }, + error: { code: 'ok' }, + }) + ) + const queryResult = await tiktokQueryVideosTool.transformResponse?.( + Response.json({ + data: { videos: [{ id: 'video-1' }] }, + error: { code: 'ok' }, + }) + ) + + expect(listResult).toMatchObject({ + success: true, + output: { videos: [{ id: 'video-1' }], cursor: 123, hasMore: false }, + }) + expect(queryResult).toMatchObject({ + success: true, + output: { videos: [{ id: 'video-1' }] }, + }) + }) + + it('rejects a malformed list payload instead of returning empty success', async () => { + const result = await tiktokListVideosTool.transformResponse?.( + Response.json({ + data: { videos: [{ id: 'video-1' }] }, + error: { code: 'ok' }, + }) + ) + + expect(result).toEqual({ + success: false, + output: { videos: [], cursor: null, hasMore: false }, + error: 'TikTok returned an unexpected data shape', + }) + }) + + it('rejects a null query payload instead of returning empty success', async () => { + const result = await tiktokQueryVideosTool.transformResponse?.( + Response.json({ data: null, error: { code: 'ok' } }) + ) + + expect(result).toEqual({ + success: false, + output: { videos: [] }, + error: 'No video query data returned', + }) + }) +}) diff --git a/apps/sim/triggers/tiktok/index.ts b/apps/sim/triggers/tiktok/index.ts index 87d4219c68c..2f9321fd3c5 100644 --- a/apps/sim/triggers/tiktok/index.ts +++ b/apps/sim/triggers/tiktok/index.ts @@ -1,8 +1,8 @@ -export { tiktokAuthorizationRemovedTrigger } from './authorization_removed' -export { tiktokPostInboxDeliveredTrigger } from './post_inbox_delivered' -export { tiktokPostNoLongerPublicTrigger } from './post_no_longer_public' -export { tiktokPostPubliclyAvailableTrigger } from './post_publicly_available' -export { tiktokPostPublishCompleteTrigger } from './post_publish_complete' -export { tiktokPostPublishFailedTrigger } from './post_publish_failed' -export { tiktokVideoPublishCompletedTrigger } from './video_publish_completed' -export { tiktokVideoUploadFailedTrigger } from './video_upload_failed' +export { tiktokAuthorizationRemovedTrigger } from '@/triggers/tiktok/authorization_removed' +export { tiktokPostInboxDeliveredTrigger } from '@/triggers/tiktok/post_inbox_delivered' +export { tiktokPostNoLongerPublicTrigger } from '@/triggers/tiktok/post_no_longer_public' +export { tiktokPostPubliclyAvailableTrigger } from '@/triggers/tiktok/post_publicly_available' +export { tiktokPostPublishCompleteTrigger } from '@/triggers/tiktok/post_publish_complete' +export { tiktokPostPublishFailedTrigger } from '@/triggers/tiktok/post_publish_failed' +export { tiktokVideoPublishCompletedTrigger } from '@/triggers/tiktok/video_publish_completed' +export { tiktokVideoUploadFailedTrigger } from '@/triggers/tiktok/video_upload_failed' diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index ba2037042f4..61afe1084db 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 957, - zodRoutes: 957, + totalRoutes: 958, + zodRoutes: 958, nonZodRoutes: 0, } as const From b2238e76527486fbfe33c265096a0c514379328a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 13 Jul 2026 17:00:42 -0700 Subject: [PATCH 16/16] address comments --- .../auth/oauth2/callback/instagram/route.ts | 71 ++++------- .../instagram/download-media/route.test.ts | 10 +- .../tools/instagram/download-media/route.ts | 5 + .../tools/instagram/publish-carousel/route.ts | 9 +- .../tools/instagram/publish-image/route.ts | 9 +- .../api/tools/instagram/publish-reel/route.ts | 9 +- .../tools/instagram/publish-story/route.ts | 9 +- .../tools/instagram/publish-video/route.ts | 9 +- .../background/tiktok-webhook-ingress.test.ts | 2 +- apps/sim/background/tiktok-webhook-ingress.ts | 2 +- .../tiktok-webhook-targets.test.ts} | 2 +- .../tiktok-webhook-targets.ts} | 7 +- apps/sim/blocks/blocks/instagram.test.ts | 41 +++++++ apps/sim/blocks/blocks/instagram.ts | 50 ++++++-- .../api/contracts/oauth-connections.test.ts | 41 +++++++ .../lib/api/contracts/oauth-connections.ts | 41 ++++++- .../lib/integrations/instagram/constants.ts | 2 +- apps/sim/lib/oauth/instagram.test.ts | 52 ++++++++ apps/sim/lib/oauth/instagram.ts | 69 +++++++++++ apps/sim/lib/oauth/oauth.test.ts | 79 ++++++++++++ apps/sim/lib/oauth/oauth.ts | 66 +++++----- apps/sim/tools/instagram/download_media.ts | 5 +- .../tools/instagram/get_account_insights.ts | 6 +- .../get_conversation_messages.test.ts | 1 + apps/sim/tools/instagram/get_media.ts | 2 +- .../instagram/get_publishing_limit.test.ts | 50 ++++++++ .../tools/instagram/get_publishing_limit.ts | 38 ++++-- apps/sim/tools/instagram/output-properties.ts | 4 +- apps/sim/tools/instagram/publish_story.ts | 2 +- apps/sim/tools/instagram/types.ts | 10 +- apps/sim/tools/instagram/utils.test.ts | 114 ++++++++++++++++++ apps/sim/tools/instagram/utils.ts | 88 +++++++++----- 32 files changed, 729 insertions(+), 176 deletions(-) rename apps/sim/{lib/webhooks/providers/tiktok-targets.test.ts => background/tiktok-webhook-targets.test.ts} (99%) rename apps/sim/{lib/webhooks/providers/tiktok-targets.ts => background/tiktok-webhook-targets.ts} (93%) create mode 100644 apps/sim/lib/api/contracts/oauth-connections.test.ts create mode 100644 apps/sim/tools/instagram/get_publishing_limit.test.ts create mode 100644 apps/sim/tools/instagram/utils.test.ts 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 556290bfd1d..d1a5cdd0b46 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { instagramCallbackContract } from '@/lib/api/contracts/oauth-connections' @@ -17,6 +18,11 @@ import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants' +import { + parseInstagramLongLivedToken, + parseInstagramProfile, + parseInstagramShortLivedToken, +} from '@/lib/oauth/instagram' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' import { safeAccountInsert } from '@/app/api/auth/oauth/utils' @@ -27,30 +33,6 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' -interface ShortLivedTokenPayload { - access_token?: string - user_id?: string | number - permissions?: string | string[] -} - -function unwrapShortLivedToken(body: unknown): ShortLivedTokenPayload | null { - if (!body || typeof body !== 'object') return null - - const record = body as Record - - if (Array.isArray(record.data) && record.data.length > 0) { - const first = record.data[0] - if (first && typeof first === 'object') { - return first as ShortLivedTokenPayload - } - } - - 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 }) @@ -148,11 +130,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { label: 'Instagram OAuth token response', signal: request.signal, }) - const shortLived = unwrapShortLivedToken(shortLivedBody) - if (!shortLived?.access_token) { - logger.error('Instagram short-lived token response missing access_token', { - body: shortLivedBody, - }) + const shortLived = parseInstagramShortLivedToken(shortLivedBody) + if (!shortLived) { + logger.error('Instagram short-lived token response was invalid') return clearOAuthCookies( NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`) ) @@ -182,27 +162,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const longLivedBody = await readResponseJsonWithLimit<{ - access_token?: string - token_type?: string - expires_in?: number - }>(longLivedResponse, { + const longLivedBody = await readResponseJsonWithLimit(longLivedResponse, { maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, label: 'Instagram OAuth exchange response', signal: request.signal, }) + const longLived = parseInstagramLongLivedToken(longLivedBody) - if (!longLivedBody.access_token) { - logger.error('Instagram long-lived token response missing access_token', { - body: longLivedBody, - }) + if (!longLived) { + logger.error('Instagram long-lived token response was invalid') return clearOAuthCookies( NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`) ) } - const longLivedToken = longLivedBody.access_token - const expiresIn = longLivedBody.expires_in ?? 5184000 + const longLivedToken = longLived.access_token + const expiresIn = longLived.expires_in const profileUrl = new URL(`${INSTAGRAM_GRAPH_BASE}/me`) profileUrl.searchParams.set('fields', 'user_id,username,name,account_type,profile_picture_url') @@ -226,16 +201,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } - const profile = await readResponseJsonWithLimit<{ - user_id?: string | number - id?: string - username?: string - name?: string - }>(profileResponse, { + const profileBody = await readResponseJsonWithLimit(profileResponse, { maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, label: 'Instagram OAuth profile response', signal: request.signal, }) + const profile = parseInstagramProfile(profileBody) + if (!profile) { + logger.error('Instagram profile response was invalid') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + ) + } const igUserId = profile.user_id != null && profile.user_id !== '' ? String(profile.user_id) : null @@ -294,7 +271,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } else { await safeAccountInsert( { - id: `instagram_${session.user.id}_${Date.now()}`, + id: generateId(), userId: session.user.id, providerId: 'instagram', accountId: igUserId, diff --git a/apps/sim/app/api/tools/instagram/download-media/route.test.ts b/apps/sim/app/api/tools/instagram/download-media/route.test.ts index 52c49ef80b1..4d5ba6c32b2 100644 --- a/apps/sim/app/api/tools/instagram/download-media/route.test.ts +++ b/apps/sim/app/api/tools/instagram/download-media/route.test.ts @@ -293,13 +293,21 @@ describe('POST /api/tools/instagram/download-media', () => { ) expect(response.status).toBe(413) - expect(await response.json()).toMatchObject({ success: false }) + expect(await response.json()).toEqual({ + success: false, + error: 'Instagram media exceeds the 100 MB canonical User File limit', + }) expect(mockUploadExecutionFile).not.toHaveBeenCalled() expect(mockUploadCopilotFile).not.toHaveBeenCalled() }) }) describe('instagramDownloadMediaTool', () => { + it('documents the canonical User File size limit', () => { + expect(instagramDownloadMediaTool.description).toContain('100 MB max per file') + expect(instagramDownloadMediaTool.outputs?.files.description).toContain('100 MB max each') + }) + it('forwards execution context and returns canonical file-array output', async () => { const body = instagramDownloadMediaTool.request.body?.({ accessToken: 'instagram-token', diff --git a/apps/sim/app/api/tools/instagram/download-media/route.ts b/apps/sim/app/api/tools/instagram/download-media/route.ts index 956398ddec5..c4611a4d488 100644 --- a/apps/sim/app/api/tools/instagram/download-media/route.ts +++ b/apps/sim/app/api/tools/instagram/download-media/route.ts @@ -367,6 +367,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { await rollbackStoredFiles(files, storageContext) logger.error('Instagram media download failed', { error }) + + if (isPayloadSizeLimitError(error) && error.maxBytes === MAX_FILE_SIZE) { + return failureResponse('Instagram media exceeds the 100 MB canonical User File limit', 413) + } + return failureResponse( getErrorMessage(error, 'Failed to download Instagram media'), isPayloadSizeLimitError(error) ? 413 : 500 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 0c5d0b6cfaa..a1f73b9d6af 100644 --- a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -1,10 +1,9 @@ -import { createLogger } from '@sim/logger' +import { createLogger, getRequestContext } 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 { @@ -24,12 +23,12 @@ export const maxDuration = 900 const logger = createLogger('InstagramPublishCarouselAPI') export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() + const requestId = getRequestContext()?.requestId ?? 'unknown' try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized Instagram publish carousel: ${authResult.error}`) + logger.warn('Unauthorized Instagram publish carousel', { error: authResult.error }) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } @@ -118,7 +117,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { containerId, mediaId, statusCode }, }) } catch (error) { - logger.error(`[${requestId}] Instagram publish carousel failed:`, error) + logger.error('Instagram publish carousel failed', { error }) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/instagram/publish-image/route.ts b/apps/sim/app/api/tools/instagram/publish-image/route.ts index 90cb8b62666..8b97b5c9228 100644 --- a/apps/sim/app/api/tools/instagram/publish-image/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-image/route.ts @@ -1,10 +1,9 @@ -import { createLogger } from '@sim/logger' +import { createLogger, getRequestContext } 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 { @@ -21,12 +20,12 @@ export const maxDuration = 600 const logger = createLogger('InstagramPublishImageAPI') export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() + const requestId = getRequestContext()?.requestId ?? 'unknown' try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized Instagram publish image: ${authResult.error}`) + logger.warn('Unauthorized Instagram publish image', { error: authResult.error }) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } @@ -91,7 +90,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { containerId, mediaId, statusCode }, }) } catch (error) { - logger.error(`[${requestId}] Instagram publish image failed:`, error) + logger.error('Instagram publish image failed', { error }) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/instagram/publish-reel/route.ts b/apps/sim/app/api/tools/instagram/publish-reel/route.ts index e59a2993489..b68a23da438 100644 --- a/apps/sim/app/api/tools/instagram/publish-reel/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-reel/route.ts @@ -1,10 +1,9 @@ -import { createLogger } from '@sim/logger' +import { createLogger, getRequestContext } 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 { @@ -21,12 +20,12 @@ export const maxDuration = 600 const logger = createLogger('InstagramPublishReelAPI') export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() + const requestId = getRequestContext()?.requestId ?? 'unknown' try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized Instagram publish reel: ${authResult.error}`) + logger.warn('Unauthorized Instagram publish reel', { error: authResult.error }) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } @@ -119,7 +118,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { containerId, mediaId, statusCode }, }) } catch (error) { - logger.error(`[${requestId}] Instagram publish reel failed:`, error) + logger.error('Instagram publish reel failed', { error }) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/instagram/publish-story/route.ts b/apps/sim/app/api/tools/instagram/publish-story/route.ts index b80b90473fc..bee2b6ed62e 100644 --- a/apps/sim/app/api/tools/instagram/publish-story/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-story/route.ts @@ -1,10 +1,9 @@ -import { createLogger } from '@sim/logger' +import { createLogger, getRequestContext } 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 { @@ -21,12 +20,12 @@ export const maxDuration = 600 const logger = createLogger('InstagramPublishStoryAPI') export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() + const requestId = getRequestContext()?.requestId ?? 'unknown' try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized Instagram publish story: ${authResult.error}`) + logger.warn('Unauthorized Instagram publish story', { error: authResult.error }) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } @@ -93,7 +92,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { containerId, mediaId, statusCode }, }) } catch (error) { - logger.error(`[${requestId}] Instagram publish story failed:`, error) + logger.error('Instagram publish story failed', { error }) return NextResponse.json( { success: false, diff --git a/apps/sim/app/api/tools/instagram/publish-video/route.ts b/apps/sim/app/api/tools/instagram/publish-video/route.ts index dcc58c67870..d2eb3fe5b3e 100644 --- a/apps/sim/app/api/tools/instagram/publish-video/route.ts +++ b/apps/sim/app/api/tools/instagram/publish-video/route.ts @@ -1,10 +1,9 @@ -import { createLogger } from '@sim/logger' +import { createLogger, getRequestContext } 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 { @@ -21,12 +20,12 @@ export const maxDuration = 600 const logger = createLogger('InstagramPublishVideoAPI') export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() + const requestId = getRequestContext()?.requestId ?? 'unknown' try { const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) if (!authResult.success || !authResult.userId) { - logger.warn(`[${requestId}] Unauthorized Instagram publish video: ${authResult.error}`) + logger.warn('Unauthorized Instagram publish video', { error: authResult.error }) return NextResponse.json( { success: false, error: authResult.error || 'Authentication required' }, { status: 401 } @@ -116,7 +115,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { output: { containerId, mediaId, statusCode }, }) } catch (error) { - logger.error(`[${requestId}] Instagram publish video failed:`, error) + logger.error('Instagram publish video failed', { error }) return NextResponse.json( { success: false, diff --git a/apps/sim/background/tiktok-webhook-ingress.test.ts b/apps/sim/background/tiktok-webhook-ingress.test.ts index 0fad01bf483..4266347cf74 100644 --- a/apps/sim/background/tiktok-webhook-ingress.test.ts +++ b/apps/sim/background/tiktok-webhook-ingress.test.ts @@ -19,7 +19,7 @@ vi.mock('@/lib/webhooks/processor', () => ({ dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget, })) -vi.mock('@/lib/webhooks/providers/tiktok-targets', () => ({ +vi.mock('@/background/tiktok-webhook-targets', () => ({ findTikTokWebhookTargetPage: mockFindTikTokWebhookTargetPage, })) vi.mock('@/lib/core/async-jobs', () => ({ diff --git a/apps/sim/background/tiktok-webhook-ingress.ts b/apps/sim/background/tiktok-webhook-ingress.ts index 369c88dd7c9..42235b20651 100644 --- a/apps/sim/background/tiktok-webhook-ingress.ts +++ b/apps/sim/background/tiktok-webhook-ingress.ts @@ -4,7 +4,7 @@ import { NextRequest } from 'next/server' import type { TikTokWebhookEnvelope } from '@/lib/api/contracts/webhooks' import { getJobQueue } from '@/lib/core/async-jobs' import { dispatchResolvedWebhookTarget } from '@/lib/webhooks/processor' -import { findTikTokWebhookTargetPage } from '@/lib/webhooks/providers/tiktok-targets' +import { findTikTokWebhookTargetPage } from '@/background/tiktok-webhook-targets' const logger = createLogger('TikTokWebhookIngressTask') diff --git a/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts b/apps/sim/background/tiktok-webhook-targets.test.ts similarity index 99% rename from apps/sim/lib/webhooks/providers/tiktok-targets.test.ts rename to apps/sim/background/tiktok-webhook-targets.test.ts index 7af9c6ca373..d8acfbf1299 100644 --- a/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts +++ b/apps/sim/background/tiktok-webhook-targets.test.ts @@ -96,7 +96,7 @@ vi.mock('drizzle-orm', () => ({ import { findTikTokWebhookTargetPage, TIKTOK_WEBHOOK_TARGET_PAGE_SIZE, -} from '@/lib/webhooks/providers/tiktok-targets' +} from '@/background/tiktok-webhook-targets' const ACCOUNT_UUID = '11111111-2222-3333-4444-555555555555' diff --git a/apps/sim/lib/webhooks/providers/tiktok-targets.ts b/apps/sim/background/tiktok-webhook-targets.ts similarity index 93% rename from apps/sim/lib/webhooks/providers/tiktok-targets.ts rename to apps/sim/background/tiktok-webhook-targets.ts index 79ab0448dfe..609bf18eb38 100644 --- a/apps/sim/lib/webhooks/providers/tiktok-targets.ts +++ b/apps/sim/background/tiktok-webhook-targets.ts @@ -36,10 +36,9 @@ function openIdFromAccountId(accountId: string): string { } /** - * Resolves one deterministic page of active webhook targets for a TikTok user_openid through the - * credential ID persisted in providerConfig. The workflow-workspace equality prevents - * cross-tenant event routing, while the webhook ID cursor keeps each query and retained result set - * bounded without offset drift. + * Resolves one deterministic page of active webhook targets for the TikTok background ingress. + * The workflow-workspace equality prevents cross-tenant event routing, while the webhook ID cursor + * keeps each query and retained result set bounded without offset drift. */ export async function findTikTokWebhookTargetPage( userOpenId: string, diff --git a/apps/sim/blocks/blocks/instagram.test.ts b/apps/sim/blocks/blocks/instagram.test.ts index 186195735c3..d6f12933a03 100644 --- a/apps/sim/blocks/blocks/instagram.test.ts +++ b/apps/sim/blocks/blocks/instagram.test.ts @@ -41,4 +41,45 @@ describe('InstagramBlock', () => { 'Unsupported Instagram operation' ) }) + + it('offers only current account insight periods and requires a demographic timeframe', () => { + const period = InstagramBlock.subBlocks.find((subBlock) => subBlock.id === 'period') + const timeframe = InstagramBlock.subBlocks.find((subBlock) => subBlock.id === 'timeframe') + + expect(period?.options?.map((option) => option.id)).toEqual(['day', 'lifetime']) + expect(timeframe?.options?.map((option) => option.id)).toEqual(['this_week', 'this_month']) + expect(timeframe?.value?.()).toBe('this_month') + expect(timeframe?.required).toEqual({ + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'lifetime' }, + }) + }) + + it('clears account insight parameters that do not apply to the selected period', () => { + const day = buildParams({ + operation: 'instagram_get_account_insights', + period: 'day', + since: '2026-07-01', + until: '2026-07-13', + timeframe: 'this_month', + }) + expect(day).toMatchObject({ period: 'day', since: '2026-07-01', until: '2026-07-13' }) + expect(day.timeframe).toBeUndefined() + + const lifetime = buildParams({ + operation: 'instagram_get_account_insights', + period: 'lifetime', + since: '2026-07-01', + until: '2026-07-13', + timeframe: 'this_month', + }) + expect(lifetime).toMatchObject({ period: 'lifetime', timeframe: 'this_month' }) + expect(lifetime.since).toBeUndefined() + expect(lifetime.until).toBeUndefined() + + expect(() => + buildParams({ operation: 'instagram_get_account_insights', period: 'week' }) + ).toThrow('Instagram account insights period must be day or lifetime') + }) }) diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts index e9bdd4c95aa..2c346d00ce6 100644 --- a/apps/sim/blocks/blocks/instagram.ts +++ b/apps/sim/blocks/blocks/instagram.ts @@ -547,11 +547,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: 'Month', id: 'month' }, { label: 'Lifetime', id: 'lifetime' }, - { label: 'Total Over Range', id: 'total_over_range' }, ], value: () => 'day', condition: { field: 'operation', value: 'instagram_get_account_insights' }, @@ -563,7 +559,11 @@ Return ONLY the comma-separated metric names - no explanations, no extra text.`, type: 'short-input', placeholder: 'Unix timestamp or YYYY-MM-DD', mode: 'advanced', - condition: { field: 'operation', value: 'instagram_get_account_insights' }, + condition: { + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'day' }, + }, wandConfig: { enabled: true, generationType: 'timestamp', @@ -582,7 +582,11 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, type: 'short-input', placeholder: 'Unix timestamp or YYYY-MM-DD', mode: 'advanced', - condition: { field: 'operation', value: 'instagram_get_account_insights' }, + condition: { + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'day' }, + }, wandConfig: { enabled: true, generationType: 'timestamp', @@ -621,13 +625,21 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, title: 'Demographic Timeframe', type: 'dropdown', options: [ - { label: 'Default', id: '' }, { label: 'This Week', id: 'this_week' }, { label: 'This Month', id: 'this_month' }, ], - value: () => '', + value: () => 'this_month', mode: 'advanced', - condition: { field: 'operation', value: 'instagram_get_account_insights' }, + condition: { + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'lifetime' }, + }, + required: { + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'lifetime' }, + }, }, { @@ -751,6 +763,17 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, } } + if (operation === 'instagram_get_account_insights') { + if (result.period === 'day') { + result.timeframe = undefined + } else if (result.period === 'lifetime') { + result.since = undefined + result.until = undefined + } else { + throw new Error('Instagram account insights period must be day or lifetime') + } + } + return result }, }, @@ -795,14 +818,14 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, 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' }, + period: { type: 'string', description: 'Account insight period: day or lifetime' }, since: { type: 'string', description: 'Account insights range start' }, until: { type: 'string', description: 'Account insights range end' }, metricType: { type: 'string', description: 'Account insights metric_type' }, breakdown: { type: 'string', description: 'Account insights breakdown dimension' }, timeframe: { type: 'string', - description: 'Lookback window required for demographic insight metrics', + description: 'Demographic insight timeframe: this_week or this_month', }, limit: { type: 'number', description: 'Maximum number of results' }, after: { type: 'string', description: 'Pagination cursor' }, @@ -869,7 +892,8 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, }, files: { type: 'file[]', - description: 'Downloaded media as canonical User Files, ordered by carousel position', + description: + 'Downloaded media as canonical User Files (100 MB max each), ordered by carousel position', condition: { field: 'operation', value: 'instagram_download_media' }, }, downloadedCount: { @@ -897,7 +921,7 @@ Return ONLY the timestamp or date - no explanations, no extra text.`, }, mediaUrl: { type: 'string', - description: 'Temporary Instagram CDN URL', + description: 'Instagram media URL when available', condition: { field: 'operation', value: 'instagram_get_media' }, }, permalink: { diff --git a/apps/sim/lib/api/contracts/oauth-connections.test.ts b/apps/sim/lib/api/contracts/oauth-connections.test.ts new file mode 100644 index 00000000000..a778ddf549f --- /dev/null +++ b/apps/sim/lib/api/contracts/oauth-connections.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + instagramAuthorizeQuerySchema, + instagramCallbackQuerySchema, +} from '@/lib/api/contracts/oauth-connections' + +describe('Instagram OAuth query contracts', () => { + it('accepts bounded authorize and callback values', () => { + expect( + instagramAuthorizeQuerySchema.safeParse({ + returnUrl: 'https://sim.ai/workspace/example', + workspaceId: 'workspace-1', + }).success + ).toBe(true) + expect( + instagramCallbackQuerySchema.safeParse({ + code: 'authorization-code', + state: 'oauth-state', + }).success + ).toBe(true) + }) + + it('rejects oversized return URLs before they can be persisted in a cookie', () => { + expect( + instagramAuthorizeQuerySchema.safeParse({ + returnUrl: `https://sim.ai/${'a'.repeat(2048)}`, + }).success + ).toBe(false) + }) + + it('rejects oversized callback fields', () => { + expect(instagramCallbackQuerySchema.safeParse({ code: 'a'.repeat(8193) }).success).toBe(false) + expect(instagramCallbackQuerySchema.safeParse({ state: 'a'.repeat(257) }).success).toBe(false) + expect( + instagramCallbackQuerySchema.safeParse({ error_description: 'a'.repeat(2049) }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 8c110313007..1c447b700f3 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -191,8 +191,17 @@ export const trelloCallbackContract = defineRouteContract({ response: { mode: 'text' }, }) +const MAX_OAUTH_RETURN_URL_LENGTH = 2048 +const MAX_OAUTH_CODE_LENGTH = 8192 +const MAX_OAUTH_STATE_LENGTH = 256 +const MAX_OAUTH_ERROR_LENGTH = 2048 + export const instagramAuthorizeQuerySchema = z.object({ - returnUrl: z.string().optional(), + returnUrl: z + .string() + .min(1, 'Return URL cannot be empty') + .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') + .optional(), workspaceId: workspaceIdSchema.optional(), }) @@ -204,11 +213,31 @@ export const authorizeInstagramContract = defineRouteContract({ }) export const instagramCallbackQuerySchema = z.object({ - code: z.string().optional(), - state: z.string().optional(), - error: z.string().optional(), - error_reason: z.string().optional(), - error_description: z.string().optional(), + code: z + .string() + .min(1, 'Authorization code cannot be empty') + .max(MAX_OAUTH_CODE_LENGTH, 'Authorization code is too long') + .optional(), + state: z + .string() + .min(1, 'OAuth state cannot be empty') + .max(MAX_OAUTH_STATE_LENGTH, 'OAuth state is too long') + .optional(), + error: z + .string() + .min(1, 'OAuth error cannot be empty') + .max(MAX_OAUTH_ERROR_LENGTH, 'OAuth error is too long') + .optional(), + error_reason: z + .string() + .min(1, 'OAuth error reason cannot be empty') + .max(MAX_OAUTH_ERROR_LENGTH, 'OAuth error reason is too long') + .optional(), + error_description: z + .string() + .min(1, 'OAuth error description cannot be empty') + .max(MAX_OAUTH_ERROR_LENGTH, 'OAuth error description is too long') + .optional(), }) export const instagramCallbackContract = defineRouteContract({ diff --git a/apps/sim/lib/integrations/instagram/constants.ts b/apps/sim/lib/integrations/instagram/constants.ts index cc641f5584f..a45c3641448 100644 --- a/apps/sim/lib/integrations/instagram/constants.ts +++ b/apps/sim/lib/integrations/instagram/constants.ts @@ -1,2 +1,2 @@ -export const INSTAGRAM_GRAPH_VERSION = 'v22.0' +export const INSTAGRAM_GRAPH_VERSION = 'v25.0' export const INSTAGRAM_GRAPH_BASE = `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}` diff --git a/apps/sim/lib/oauth/instagram.test.ts b/apps/sim/lib/oauth/instagram.test.ts index f767f20011e..de3358991f0 100644 --- a/apps/sim/lib/oauth/instagram.test.ts +++ b/apps/sim/lib/oauth/instagram.test.ts @@ -3,6 +3,9 @@ import { INSTAGRAM_MIN_TOKEN_AGE_MS, INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS, isInstagramProvider, + parseInstagramLongLivedToken, + parseInstagramProfile, + parseInstagramShortLivedToken, shouldProactivelyRefreshInstagramToken, } from '@/lib/oauth/instagram' @@ -59,4 +62,53 @@ describe('instagram oauth helpers', () => { }) ).toBe(false) }) + + it('parses direct and data-array short-lived token responses', () => { + expect( + parseInstagramShortLivedToken({ + access_token: 'short-token', + user_id: 123, + permissions: ['instagram_business_basic'], + }) + ).toEqual({ + access_token: 'short-token', + user_id: 123, + permissions: ['instagram_business_basic'], + }) + expect( + parseInstagramShortLivedToken({ data: [{ access_token: 'wrapped-token', user_id: '456' }] }) + ).toEqual({ access_token: 'wrapped-token', user_id: '456' }) + }) + + it('rejects malformed or oversized token responses', () => { + expect(parseInstagramShortLivedToken({ access_token: 123 })).toBeNull() + expect( + parseInstagramLongLivedToken({ access_token: 'token', expires_in: '5184000' }) + ).toBeNull() + expect(parseInstagramLongLivedToken({ access_token: 'token' })).toBeNull() + expect( + parseInstagramLongLivedToken({ + access_token: 'token', + expires_in: 366 * 24 * 60 * 60, + }) + ).toBeNull() + }) + + it('parses bounded long-lived token and profile responses', () => { + expect( + parseInstagramLongLivedToken({ + access_token: 'long-token', + token_type: 'bearer', + expires_in: 5_184_000, + }) + ).toEqual({ + access_token: 'long-token', + token_type: 'bearer', + expires_in: 5_184_000, + }) + expect( + parseInstagramProfile({ user_id: 123, id: 'graph-id', username: 'sim', name: 'Sim' }) + ).toEqual({ user_id: 123, id: 'graph-id', username: 'sim', name: 'Sim' }) + expect(parseInstagramProfile({ user_id: { nested: true } })).toBeNull() + }) }) diff --git a/apps/sim/lib/oauth/instagram.ts b/apps/sim/lib/oauth/instagram.ts index 3fe5be1c35a..34623bf285e 100644 --- a/apps/sim/lib/oauth/instagram.ts +++ b/apps/sim/lib/oauth/instagram.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' + /** Refresh while the long-lived token still has this many days left. */ export const INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS = 14 @@ -7,6 +9,73 @@ export const INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS = 14 */ export const INSTAGRAM_MIN_TOKEN_AGE_MS = 24 * 60 * 60 * 1000 +const INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH = 8192 +const INSTAGRAM_GRAPH_ID_MAX_LENGTH = 256 +const INSTAGRAM_PERMISSION_MAX_LENGTH = 256 +const INSTAGRAM_PERMISSION_COUNT_MAX = 64 +const INSTAGRAM_PROFILE_TEXT_MAX_LENGTH = 512 +const INSTAGRAM_TOKEN_TYPE_MAX_LENGTH = 64 +const INSTAGRAM_TOKEN_LIFETIME_MAX_SECONDS = 365 * 24 * 60 * 60 + +const instagramGraphIdSchema = z.union([ + z.string().min(1).max(INSTAGRAM_GRAPH_ID_MAX_LENGTH), + z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), +]) + +const instagramShortLivedTokenSchema = z.object({ + access_token: z.string().min(1).max(INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH), + user_id: instagramGraphIdSchema.optional(), + permissions: z + .union([ + z.string().min(1).max(INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH), + z + .array(z.string().min(1).max(INSTAGRAM_PERMISSION_MAX_LENGTH)) + .max(INSTAGRAM_PERMISSION_COUNT_MAX), + ]) + .optional(), +}) + +const instagramShortLivedTokenResponseSchema = z.union([ + instagramShortLivedTokenSchema, + z.object({ data: z.array(instagramShortLivedTokenSchema).length(1) }), +]) + +export const instagramLongLivedTokenResponseSchema = z.object({ + access_token: z.string().min(1).max(INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH), + token_type: z.string().min(1).max(INSTAGRAM_TOKEN_TYPE_MAX_LENGTH).optional(), + expires_in: z.number().int().positive().max(INSTAGRAM_TOKEN_LIFETIME_MAX_SECONDS), +}) + +export const instagramProfileResponseSchema = z.object({ + user_id: instagramGraphIdSchema.optional(), + id: instagramGraphIdSchema.optional(), + username: z.string().max(INSTAGRAM_PROFILE_TEXT_MAX_LENGTH).optional(), + name: z.string().max(INSTAGRAM_PROFILE_TEXT_MAX_LENGTH).optional(), +}) + +export type InstagramShortLivedToken = z.output +export type InstagramLongLivedToken = z.output +export type InstagramProfile = z.output + +/** Parses the direct or legacy data-array shape returned by Instagram's code exchange. */ +export function parseInstagramShortLivedToken(value: unknown): InstagramShortLivedToken | null { + const parsed = instagramShortLivedTokenResponseSchema.safeParse(value) + if (!parsed.success) return null + return 'data' in parsed.data ? parsed.data.data[0] : parsed.data +} + +/** Parses and bounds the long-lived access-token exchange response. */ +export function parseInstagramLongLivedToken(value: unknown): InstagramLongLivedToken | null { + const parsed = instagramLongLivedTokenResponseSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +/** Parses the subset of the Instagram profile response persisted during OAuth. */ +export function parseInstagramProfile(value: unknown): InstagramProfile | null { + const parsed = instagramProfileResponseSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + export function isInstagramProvider(providerId: string): boolean { return providerId === 'instagram' } diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index 60b66c23b32..339b1a7694a 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -11,6 +11,8 @@ vi.mock('@/lib/core/config/env', () => X_CLIENT_SECRET: 'x_client_secret', TIKTOK_CLIENT_ID: 'tiktok_client_key', TIKTOK_CLIENT_SECRET: 'tiktok_client_secret', + INSTAGRAM_CLIENT_ID: 'instagram_client_id', + INSTAGRAM_CLIENT_SECRET: 'instagram_client_secret', CONFLUENCE_CLIENT_ID: 'confluence_client_id', CONFLUENCE_CLIENT_SECRET: 'confluence_client_secret', JIRA_CLIENT_ID: 'jira_client_id', @@ -54,6 +56,7 @@ vi.mock('@/lib/core/config/env', () => }) ) +import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' import { refreshOAuthToken } from '@/lib/oauth' /** @@ -525,4 +528,80 @@ describe('OAuth Token Refresh', () => { }) }) }) + + describe('Instagram Token Refresh', () => { + it.concurrent('validates and rotates the long-lived token response', async () => { + const mockFetch = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'new_instagram_access_token', + token_type: 'bearer', + expires_in: 5_184_000, + }) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result).toEqual({ + ok: true, + accessToken: 'new_instagram_access_token', + expiresIn: 5_184_000, + refreshToken: 'new_instagram_access_token', + }) + expect(mockFetch).toHaveBeenCalledWith( + 'https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=old_instagram_access_token', + expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }) + ) + }) + + it.concurrent('rejects malformed long-lived token response fields', async () => { + const mockFetch = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'new_instagram_access_token', + expires_in: '5184000', + }) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result).toEqual({ + ok: false, + message: 'Invalid Instagram token refresh response', + }) + }) + + it.concurrent('extracts nested Meta error codes from a bounded response', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue( + Response.json( + { error: { message: 'Invalid OAuth access token', type: 'OAuthException', code: 190 } }, + { status: 400 } + ) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.errorCode).toBe('190') + }) + + it.concurrent('rejects oversized token responses before materializing them', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(new Response('x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1))) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.message).toContain('exceeds maximum size') + }) + }) }) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index 9b98a90bb6d..e93ec7be5c4 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { AirtableIcon, AsanaIcon, @@ -54,6 +55,11 @@ import { ZoomIcon, } from '@/components/icons' import { env } from '@/lib/core/config/env' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram' import type { OAuthProviderConfig } from './types' const logger = createLogger('OAuth') @@ -1589,8 +1595,12 @@ export type RefreshTokenResult = RefreshTokenSuccess | RefreshTokenFailure function extractErrorCode(value: unknown): string | undefined { if (value && typeof value === 'object' && 'error' in value) { - const code = (value as { error: unknown }).error - if (typeof code === 'string') return code + const error = (value as { error: unknown }).error + if (typeof error === 'string') return error + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code: unknown }).code + if (typeof code === 'string' || typeof code === 'number') return String(code) + } } return undefined } @@ -1618,49 +1628,51 @@ async function refreshInstagramLongLivedToken( signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS), }) + const responseText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram token refresh response', + }) + let responseData: unknown = responseText + try { + responseData = JSON.parse(responseText) + } catch { + responseData = responseText + } + if (!response.ok) { - const errorText = await response.text() - let errorData: unknown = errorText - try { - errorData = JSON.parse(errorText) - } catch { - // keep text - } + const errorSummary = truncate(responseText, 1000) logger.error('Instagram long-lived token refresh failed:', { status: response.status, statusText: response.statusText, - error: errorText, - parsedError: errorData, + error: errorSummary, + parsedError: responseData, providerId, tokenEndpoint: config.tokenEndpoint, }) return { ok: false, - errorCode: extractErrorCode(errorData), - message: `Failed to refresh token: ${response.status} ${errorText}`, + errorCode: extractErrorCode(responseData), + message: `Failed to refresh token: ${response.status} ${errorSummary}`, } } - 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' } + const payload = parseInstagramLongLivedToken(responseData) + if (!payload) { + logger.warn('Invalid Instagram refresh response', { providerId }) + return { ok: false, message: 'Invalid Instagram token refresh response' } } - logger.info('Instagram long-lived token refreshed successfully', { expiresIn, providerId }) + logger.info('Instagram long-lived token refreshed successfully', { + expiresIn: payload.expires_in, + providerId, + }) // Instagram returns a new long-lived token; store it as both access and refresh. return { ok: true, - accessToken, - expiresIn, - refreshToken: accessToken, + accessToken: payload.access_token, + expiresIn: payload.expires_in, + refreshToken: payload.access_token, } } diff --git a/apps/sim/tools/instagram/download_media.ts b/apps/sim/tools/instagram/download_media.ts index 4fe39626d21..2c8d15f8ac8 100644 --- a/apps/sim/tools/instagram/download_media.ts +++ b/apps/sim/tools/instagram/download_media.ts @@ -28,7 +28,7 @@ export const instagramDownloadMediaTool: ToolConfig< id: 'instagram_download_media', name: 'Instagram Download Media', description: - 'Download an Instagram image, video, Reel, story, or ordered carousel into canonical User Files for downstream file inputs', + 'Download Instagram media into canonical User Files for downstream file inputs (100 MB max per file)', version: '1.0.0', oauth: { @@ -126,7 +126,8 @@ export const instagramDownloadMediaTool: ToolConfig< outputs: { files: { type: 'file[]', - description: 'Downloaded media as canonical User Files, ready for attachment inputs', + description: + 'Downloaded media as canonical User Files, ready for attachment inputs (100 MB max each)', }, mediaId: { type: 'string', description: 'Instagram media ID that was downloaded' }, mediaType: { diff --git a/apps/sim/tools/instagram/get_account_insights.ts b/apps/sim/tools/instagram/get_account_insights.ts index 64822e0bc95..28bb2a5b0bf 100644 --- a/apps/sim/tools/instagram/get_account_insights.ts +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -44,13 +44,13 @@ export const instagramGetAccountInsightsTool: ToolConfig< required: true, visibility: 'user-or-llm', description: - 'Comma-separated metrics (e.g. reach,views,accounts_engaged,profile_views,follower_count)', + 'Comma-separated current metrics (e.g. reach,views,accounts_engaged,likes,comments,saves,shares,total_interactions)', }, period: { type: 'string', required: true, visibility: 'user-or-llm', - description: 'Aggregation period: day, week, days_28, month, lifetime, or total_over_range', + description: 'Use day for interaction metrics or lifetime for demographic metrics', }, since: { type: 'string', @@ -80,7 +80,7 @@ export const instagramGetAccountInsightsTool: ToolConfig< type: 'string', required: false, visibility: 'user-or-llm', - description: 'Required lookback window for demographic metrics', + description: 'Required for demographic metrics: this_week or this_month', }, }, diff --git a/apps/sim/tools/instagram/get_conversation_messages.test.ts b/apps/sim/tools/instagram/get_conversation_messages.test.ts index d40c0fb2654..3eedc79fcbc 100644 --- a/apps/sim/tools/instagram/get_conversation_messages.test.ts +++ b/apps/sim/tools/instagram/get_conversation_messages.test.ts @@ -21,6 +21,7 @@ describe('instagramGetConversationMessagesTool', () => { }) ) + expect(url.pathname.startsWith('/v25.0/')).toBe(true) expect(url.pathname.endsWith('/conversation-1')).toBe(true) expect(url.searchParams.get('fields')).toBe( 'messages.limit(7).after(cursor-2){id,created_time,is_unsupported}' diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts index 248fd56eba9..0b3fb472466 100644 --- a/apps/sim/tools/instagram/get_media.ts +++ b/apps/sim/tools/instagram/get_media.ts @@ -113,7 +113,7 @@ export const instagramGetMediaTool: ToolConfig { + it('maps and describes the structured quota config', async () => { + const result = await instagramGetPublishingLimitTool.transformResponse?.( + Response.json({ + data: [ + { + quota_usage: 12, + config: { quota_total: 100, quota_duration: 86_400 }, + }, + ], + }), + { accessToken: 'token' } + ) + + expect(result).toEqual({ + success: true, + output: { + quotaUsage: 12, + config: { quotaTotal: 100, quotaDuration: 86_400 }, + }, + }) + expect(instagramGetPublishingLimitTool.outputs?.config.properties).toMatchObject({ + quotaTotal: { type: 'number', nullable: true }, + quotaDuration: { type: 'number', nullable: true }, + }) + }) + + it('normalizes malformed quota values instead of leaking invalid output types', async () => { + const result = await instagramGetPublishingLimitTool.transformResponse?.( + Response.json({ + data: [{ quota_usage: '12', config: { quota_total: '100', quota_duration: null } }], + }), + { accessToken: 'token' } + ) + + expect(result).toEqual({ + success: true, + output: { + quotaUsage: null, + config: { quotaTotal: null, quotaDuration: null }, + }, + }) + }) +}) diff --git a/apps/sim/tools/instagram/get_publishing_limit.ts b/apps/sim/tools/instagram/get_publishing_limit.ts index 97cb80d2d17..9f1fc4432f3 100644 --- a/apps/sim/tools/instagram/get_publishing_limit.ts +++ b/apps/sim/tools/instagram/get_publishing_limit.ts @@ -11,6 +11,23 @@ import { } from '@/tools/instagram/utils' import type { ToolConfig } from '@/tools/types' +function finiteNumberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function parsePublishingLimitConfig(value: unknown): { + quotaTotal: number | null + quotaDuration: number | null +} | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + + const config = value as Record + return { + quotaTotal: finiteNumberOrNull(config.quota_total), + quotaDuration: finiteNumberOrNull(config.quota_duration), + } +} + interface PublishingLimitData { quota_usage?: number config?: { @@ -77,13 +94,8 @@ export const instagramGetPublishingLimitTool: ToolConfig< 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, + quotaUsage: finiteNumberOrNull(first?.quota_usage), + config: parsePublishingLimitConfig(first?.config), }, } }, @@ -98,6 +110,18 @@ export const instagramGetPublishingLimitTool: ToolConfig< type: 'json', description: 'Quota config (quotaTotal, quotaDuration)', optional: true, + properties: { + quotaTotal: { + type: 'number', + description: 'Total publishes allowed in the quota window', + nullable: true, + }, + quotaDuration: { + type: 'number', + description: 'Quota window duration reported by Instagram', + nullable: true, + }, + }, }, }, } diff --git a/apps/sim/tools/instagram/output-properties.ts b/apps/sim/tools/instagram/output-properties.ts index 94595426a1e..62be3df0a88 100644 --- a/apps/sim/tools/instagram/output-properties.ts +++ b/apps/sim/tools/instagram/output-properties.ts @@ -11,7 +11,7 @@ export const INSTAGRAM_MEDIA_PROPERTIES = { }, mediaUrl: { type: 'string', - description: 'Temporary Instagram CDN URL', + description: 'Instagram media URL when available', nullable: true, }, permalink: { type: 'string', description: 'Permalink to the media', nullable: true }, @@ -25,7 +25,7 @@ export const INSTAGRAM_STORY_PROPERTIES = { mediaType: { type: 'string', description: 'IMAGE or VIDEO', nullable: true }, mediaUrl: { type: 'string', - description: 'Temporary Instagram CDN URL', + description: 'Instagram story media URL when available', nullable: true, }, timestamp: { type: 'string', description: 'ISO timestamp', nullable: true }, diff --git a/apps/sim/tools/instagram/publish_story.ts b/apps/sim/tools/instagram/publish_story.ts index 385445c0304..2bec29f3af5 100644 --- a/apps/sim/tools/instagram/publish_story.ts +++ b/apps/sim/tools/instagram/publish_story.ts @@ -13,7 +13,7 @@ export const instagramPublishStoryTool: ToolConfig< id: 'instagram_publish_story', name: 'Instagram Publish Story', description: - 'Publish an image or video story for an Instagram Business account from an uploaded file or public HTTPS URL', + 'Publish an image or video story for an Instagram professional account from an uploaded file or public HTTPS URL', version: '1.0.0', oauth: { diff --git a/apps/sim/tools/instagram/types.ts b/apps/sim/tools/instagram/types.ts index c07d6d57d04..09fe29d33a2 100644 --- a/apps/sim/tools/instagram/types.ts +++ b/apps/sim/tools/instagram/types.ts @@ -316,14 +316,18 @@ export interface InstagramSendTextMessageResponse extends ToolResponse { } } +export type InstagramAccountInsightsPeriod = 'day' | 'lifetime' +export type InstagramAccountInsightsMetricType = 'time_series' | 'total_value' +export type InstagramAccountInsightsTimeframe = 'this_week' | 'this_month' + export interface InstagramGetAccountInsightsParams extends InstagramAccessParams { metrics: string - period: string + period: InstagramAccountInsightsPeriod since?: string until?: string - metricType?: string + metricType?: InstagramAccountInsightsMetricType breakdown?: string - timeframe?: string + timeframe?: InstagramAccountInsightsTimeframe } export interface InstagramGetAccountInsightsResponse extends ToolResponse { diff --git a/apps/sim/tools/instagram/utils.test.ts b/apps/sim/tools/instagram/utils.test.ts new file mode 100644 index 00000000000..8c6b1bc6e0a --- /dev/null +++ b/apps/sim/tools/instagram/utils.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createPublishTransform, INSTAGRAM_RESPONSE_MAX_BYTES } from '@/tools/instagram/utils' + +const FALLBACK_OUTPUT = { + containerId: null, + mediaId: null, + statusCode: null, +} + +const SUCCESS_OUTPUT = { + containerId: 'container-1', + mediaId: 'media-1', + statusCode: 'FINISHED', +} + +describe('createPublishTransform', () => { + const transform = createPublishTransform('Failed to publish media') + + it('returns a validated successful publish response', async () => { + const result = await transform( + Response.json({ + success: true, + output: SUCCESS_OUTPUT, + }) + ) + + expect(result).toEqual({ success: true, output: SUCCESS_OUTPUT }) + }) + + it('preserves a structured failure from the publish route', async () => { + const result = await transform( + Response.json( + { + success: false, + error: 'Container processing failed', + output: FALLBACK_OUTPUT, + }, + { status: 422 } + ) + ) + + expect(result).toEqual({ + success: false, + output: FALLBACK_OUTPUT, + error: 'Container processing failed', + }) + }) + + it('returns failure for an empty successful HTTP response', async () => { + const result = await transform(new Response('', { status: 200 })) + + expect(result).toMatchObject({ success: false, output: FALLBACK_OUTPUT }) + }) + + it('returns failure for malformed JSON in a successful HTTP response', async () => { + const result = await transform(new Response('{not-json', { status: 200 })) + + expect(result).toMatchObject({ success: false, output: FALLBACK_OUTPUT }) + }) + + it.each([ + { name: 'a missing success discriminator', body: { output: SUCCESS_OUTPUT } }, + { name: 'a missing output', body: { success: true } }, + { + name: 'null identifiers', + body: { success: true, output: FALLBACK_OUTPUT }, + }, + { + name: 'an incomplete output', + body: { + success: true, + output: { containerId: 'container-1', mediaId: 'media-1' }, + }, + }, + ])('returns failure for $name', async ({ body }) => { + const result = await transform(Response.json(body)) + + expect(result).toEqual({ + success: false, + output: FALLBACK_OUTPUT, + error: 'Failed to publish media: invalid success response', + }) + }) + + it('returns failure when the bounded response reader rejects an oversized body', async () => { + const result = await transform( + new Response('x'.repeat(INSTAGRAM_RESPONSE_MAX_BYTES + 1), { status: 200 }) + ) + + expect(result).toMatchObject({ success: false, output: FALLBACK_OUTPUT }) + expect(result.error).toContain( + `Instagram publish response exceeds maximum size of ${INSTAGRAM_RESPONSE_MAX_BYTES} bytes` + ) + }) + + it('returns failure when the response stream cannot be read', async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('response stream failed')) + }, + }) + + const result = await transform(new Response(body, { status: 200 })) + + expect(result).toEqual({ + success: false, + output: FALLBACK_OUTPUT, + error: 'response stream failed', + }) + }) +}) diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts index 3d62e305bb8..6e4a7777672 100644 --- a/apps/sim/tools/instagram/utils.ts +++ b/apps/sim/tools/instagram/utils.ts @@ -1,4 +1,4 @@ -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { DEFAULT_MAX_ERROR_BODY_BYTES, @@ -306,45 +306,73 @@ export async function publishMediaContainer( */ export function createPublishTransform(fallbackError: string) { return async (response: Response): Promise => { - const text = await readResponseTextWithLimit(response, { - maxBytes: INSTAGRAM_RESPONSE_MAX_BYTES, - label: 'Instagram publish response', - }).catch(() => '') const fallbackOutput = { containerId: null, mediaId: null, statusCode: null } + let data: unknown + + try { + data = await readResponseJsonWithLimit(response, { + maxBytes: INSTAGRAM_RESPONSE_MAX_BYTES, + label: 'Instagram publish response', + }) + } catch (error) { + return { + success: false, + output: fallbackOutput, + error: getErrorMessage(error, fallbackError), + } + } - let data: { - success?: boolean - output?: InstagramPublishResponse['output'] - error?: string - } = {} - - if (text) { - try { - data = JSON.parse(text) as typeof data - } catch { - if (!response.ok) { - return { - success: false, - output: fallbackOutput, - error: `${fallbackError}: ${text}`, - } - } - return { - success: false, - output: fallbackOutput, - error: `${fallbackError}: invalid JSON response`, - } + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { + success: false, + output: fallbackOutput, + error: `${fallbackError}: invalid response`, } } - const output = data.output ?? fallbackOutput - if (!response.ok || data.success === false) { - return { success: false, output, error: data.error || fallbackError } + const envelope = data as Record + const rawOutput = envelope.output + const output = isInstagramPublishOutput(rawOutput) ? rawOutput : fallbackOutput + const error = + typeof envelope.error === 'string' && envelope.error.trim() + ? envelope.error.trim() + : fallbackError + + if (!response.ok || envelope.success === false) { + return { success: false, output, error } + } + + if (envelope.success !== true || !isCompleteInstagramPublishOutput(rawOutput)) { + return { + success: false, + output: fallbackOutput, + error: `${fallbackError}: invalid success response`, + } } + return { success: true, output } } } +function isInstagramPublishOutput(value: unknown): value is InstagramPublishResponse['output'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + + const output = value as Record + return [output.containerId, output.mediaId, output.statusCode].every( + (field) => field === null || typeof field === 'string' + ) +} + +function isCompleteInstagramPublishOutput( + value: unknown +): value is InstagramPublishResponse['output'] { + if (!isInstagramPublishOutput(value)) return false + + return [value.containerId, value.mediaId, value.statusCode].every( + (field) => typeof field === 'string' && field.trim().length > 0 + ) +} + export function parseCommaSeparated(value?: string): string[] { if (!value) return [] return value