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..01697d4f281 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/instagram.mdx @@ -0,0 +1,467 @@ +--- +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 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 | +| --------- | ---- | ----------- | +| `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 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 | +| --------- | ---- | ----------- | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Final container status | + +### `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 | +| --------- | ---- | ----------- | +| `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 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 | +| --------- | ---- | ----------- | +| `containerId` | string | Media container id | +| `mediaId` | string | Published media id | +| `statusCode` | string | Final container status | + +### `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 | +| --------- | ---- | ----------- | +| `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 | +| `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 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..85b4437748a --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InstagramAuthorize') + +export const dynamic = 'force-dynamic' + +const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' +const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' +const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 + +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(authorizeInstagramContract, request, {}) + if (!parsed.success) return parsed.response + const { returnUrl, workspaceId } = parsed.data.query + + if (workspaceId) { + const access = await checkWorkspaceAccess(workspaceId, session.user.id) + if (!access.canWrite) { + return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + } + await createConnectDraft({ + userId: session.user.id, + workspaceId, + providerId: 'instagram', + }) + } + + const clientId = env.INSTAGRAM_CLIENT_ID + if (!clientId) { + logger.error('INSTAGRAM_CLIENT_ID not configured') + return NextResponse.json({ error: 'Instagram client ID not configured' }, { status: 500 }) + } + + const baseUrl = getBaseUrl() + const state = generateShortId(32) + const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram` + const scope = getCanonicalScopesForProvider('instagram').join(',') + + const authUrl = new URL('https://www.instagram.com/oauth/authorize') + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('redirect_uri', redirectUri) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', scope) + authUrl.searchParams.set('state', state) + + const response = NextResponse.redirect(authUrl.toString()) + response.cookies.set(INSTAGRAM_STATE_COOKIE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + + if (returnUrl && isSameOrigin(returnUrl)) { + response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } + + return response + } catch (error) { + logger.error('Error starting Instagram OAuth', { error }) + return NextResponse.json({ error: 'Failed to start Instagram OAuth' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 65bb6a773b9..2a9799d7863 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -1,81 +1,17 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getAllOAuthServices } from '@/lib/oauth/utils' +import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') export const dynamic = 'force-dynamic' -const DRAFT_TTL_MS = 15 * 60 * 1000 - -/** - * Creates the pending credential draft at click time so its TTL starts when the - * user actually initiates the connect. Better Auth's `account.create.after` hook - * consumes this draft to materialize the real credential after the OAuth - * callback; starting the clock here guarantees the draft outlives the (≤5 min) - * OAuth round-trip rather than expiring mid-flow and silently producing no - * credential. - */ -async function createConnectDraft(params: { - userId: string - workspaceId: string - providerId: string -}): Promise { - const { userId, workspaceId, providerId } = params - - const service = getAllOAuthServices().find((s) => s.providerId === providerId) - const serviceName = service?.name ?? providerId - - let displayName = serviceName - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - if (row?.name) { - displayName = `${row.name}'s ${serviceName}` - } - } catch { - // Fall back to service name only - } - - const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - expiresAt, - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { displayName, expiresAt, createdAt: now }, - }) - - logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) -} - /** * Browser-initiated entrypoint for linking a generic OAuth2 account. */ diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts new file mode 100644 index 00000000000..42e56365b5c --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -0,0 +1,324 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramCallbackContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { safeAccountInsert } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('InstagramCallback') + +export const dynamic = 'force-dynamic' + +const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' +const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' +interface ShortLivedTokenPayload { + access_token?: string + user_id?: string | number + // Meta returns granted permissions as a comma-separated string or an array + // depending on the response shape. + permissions?: string | string[] +} + +function unwrapShortLivedToken(body: unknown): ShortLivedTokenPayload | null { + if (!body || typeof body !== 'object') return null + + const record = body as Record + + // Nested shape from Instagram Login: { data: [{ access_token, user_id, permissions }] } + if (Array.isArray(record.data) && record.data.length > 0) { + const first = record.data[0] + if (first && typeof first === 'object') { + return first as ShortLivedTokenPayload + } + } + + // Flat shape fallback + if (typeof record.access_token === 'string') { + return record as ShortLivedTokenPayload + } + + return null +} + +function clearOAuthCookies(response: NextResponse) { + response.cookies.delete({ name: INSTAGRAM_STATE_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + response.cookies.delete({ name: INSTAGRAM_RETURN_URL_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + return response +} + +export const GET = withRouteHandler(async (request: NextRequest) => { + const baseUrl = getBaseUrl() + + try { + const session = await getSession() + if (!session?.user?.id) { + return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)) + } + + const parsed = await parseRequest(instagramCallbackContract, request, {}) + if (!parsed.success) return parsed.response + + const { code, state, error, error_reason, error_description } = parsed.data.query + + if (error) { + logger.warn('Instagram OAuth denied by user', { + error, + error_reason, + error_description, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_access_denied`) + ) + } + + const cookieState = request.cookies.get(INSTAGRAM_STATE_COOKIE)?.value + if (!state || !cookieState || state !== cookieState) { + logger.warn('Instagram callback rejected: state mismatch', { + hasQueryState: Boolean(state), + hasCookieState: Boolean(cookieState), + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_state_mismatch`) + ) + } + + const clientId = env.INSTAGRAM_CLIENT_ID + const clientSecret = env.INSTAGRAM_CLIENT_SECRET + if (!clientId || !clientSecret) { + logger.error('Instagram credentials not configured') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_config_error`) + ) + } + + if (!code) { + logger.error('No authorization code received from Instagram') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_code`) + ) + } + + // Instagram appends `#_` to the redirect; strip it from the code if present. + const authorizationCode = code.replace(/#_$/, '') + const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram` + + const tokenForm = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + code: authorizationCode, + }) + + const shortLivedResponse = await fetch('https://api.instagram.com/oauth/access_token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: tokenForm.toString(), + }) + + if (!shortLivedResponse.ok) { + const errorText = await shortLivedResponse.text() + logger.error('Failed to exchange Instagram authorization code', { + status: shortLivedResponse.status, + body: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`) + ) + } + + const shortLivedBody = await shortLivedResponse.json() + const shortLived = unwrapShortLivedToken(shortLivedBody) + if (!shortLived?.access_token) { + logger.error('Instagram short-lived token response missing access_token', { + body: shortLivedBody, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`) + ) + } + + const exchangeUrl = new URL('https://graph.instagram.com/access_token') + exchangeUrl.searchParams.set('grant_type', 'ig_exchange_token') + exchangeUrl.searchParams.set('client_secret', clientSecret) + exchangeUrl.searchParams.set('access_token', shortLived.access_token) + + const longLivedResponse = await fetch(exchangeUrl.toString(), { method: 'GET' }) + if (!longLivedResponse.ok) { + const errorText = await longLivedResponse.text() + logger.error('Failed to exchange Instagram short-lived token', { + status: longLivedResponse.status, + body: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`) + ) + } + + const longLivedBody = (await longLivedResponse.json()) as { + access_token?: string + token_type?: string + expires_in?: number + } + + if (!longLivedBody.access_token) { + logger.error('Instagram long-lived token response missing access_token', { + body: longLivedBody, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`) + ) + } + + const longLivedToken = longLivedBody.access_token + const expiresIn = longLivedBody.expires_in ?? 5184000 + + const profileResponse = await fetch( + `${INSTAGRAM_GRAPH_BASE}/me?fields=user_id,username,name,account_type,profile_picture_url&access_token=${encodeURIComponent(longLivedToken)}` + ) + + if (!profileResponse.ok) { + const errorText = await profileResponse.text() + logger.error('Failed to fetch Instagram profile after OAuth', { + status: profileResponse.status, + body: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + ) + } + + const profile = (await profileResponse.json()) as { + user_id?: string | number + id?: string + username?: string + name?: string + } + + // account.accountId must always be the Instagram professional account ID + // (/me user_id, which Meta may serialize as a string or number). The token + // exchange's user_id and /me's id are app-scoped IDs — a different ID + // space — so falling back to them would break reconnect dedupe by storing + // the same account under two identifiers. + const igUserId = + profile.user_id != null && profile.user_id !== '' ? String(profile.user_id) : null + + if (!igUserId) { + logger.error('Instagram profile response missing user_id', { profile }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`) + ) + } + + let grantedPermissions: string[] = [] + if (Array.isArray(shortLived.permissions)) { + grantedPermissions = shortLived.permissions.filter( + (s): s is string => typeof s === 'string' && s.length > 0 + ) + } else if (typeof shortLived.permissions === 'string' && shortLived.permissions.length > 0) { + grantedPermissions = shortLived.permissions + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + const permissions = + grantedPermissions.length > 0 + ? grantedPermissions + : getCanonicalScopesForProvider('instagram') + // Space-joined: the standard OAuth format both credential routes parse + // (`/api/auth/oauth/connections` splits on whitespace only). + const scope = permissions.join(' ') + + const now = new Date() + const accessTokenExpiresAt = new Date(now.getTime() + expiresIn * 1000) + + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, session.user.id), + eq(account.providerId, 'instagram'), + eq(account.accountId, igUserId) + ), + }) + + if (existing) { + await db + .update(account) + .set({ + accessToken: longLivedToken, + refreshToken: longLivedToken, + accessTokenExpiresAt, + scope, + updatedAt: now, + }) + .where(eq(account.id, existing.id)) + logger.info('Updated existing Instagram account', { + accountId: existing.id, + igUserId, + username: profile.username, + }) + } else { + await safeAccountInsert( + { + id: `instagram_${session.user.id}_${Date.now()}`, + userId: session.user.id, + providerId: 'instagram', + accountId: igUserId, + accessToken: longLivedToken, + refreshToken: longLivedToken, + accessTokenExpiresAt, + scope, + createdAt: now, + updatedAt: now, + }, + { provider: 'Instagram', identifier: profile.username || igUserId } + ) + logger.info('Created Instagram account', { igUserId, username: profile.username }) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, session.user.id), + eq(account.providerId, 'instagram'), + eq(account.accountId, igUserId) + ), + })) + + if (persisted) { + try { + await processCredentialDraft({ + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) + } catch (draftError) { + logger.error('Failed to process credential draft for Instagram', { error: draftError }) + } + } + + const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value + const redirectUrl = + returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('instagram_connected', 'true') + + return clearOAuthCookies(NextResponse.redirect(finalUrl.toString())) + } catch (error) { + logger.error('Error in Instagram OAuth callback', { error }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_callback_error`) + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts new file mode 100644 index 00000000000..95fd745cbdc --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -0,0 +1,117 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishCarouselContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramCarouselMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** + * Children are polled in parallel, so the worst case is one five-minute poll + * window for the children plus another for the parent container. + */ +export const maxDuration = 900 + +const logger = createLogger('InstagramPublishCarouselAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish carousel: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishCarouselContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolved = await resolveInstagramCarouselMedia( + body.media, + authResult.userId, + requestId, + logger + ) + if (resolved.error || !resolved.items) { + return NextResponse.json( + { + success: false, + error: resolved.error?.message || 'Failed to resolve carousel media', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolved.error?.status || 400 } + ) + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + + // Create every child container before polling any of them: Meta fetches each + // media URL at container creation, so presigned links are consumed while + // fresh, and polling in parallel bounds the total wait to a single + // five-minute window instead of five minutes per item. + const childIds: string[] = [] + for (const item of resolved.items) { + const childBody: Record = { + is_carousel_item: true, + } + if (item.kind === 'video') { + childBody.media_type = 'VIDEO' + childBody.video_url = item.url + } else { + childBody.image_url = item.url + } + childIds.push(await createMediaContainer(body.accessToken, igUserId, childBody)) + } + + // allSettled so a fast-failing child doesn't leave sibling polls rejecting + // with no handler (Node terminates on unhandled rejections). + const childResults = await Promise.allSettled( + childIds.map((childId) => waitForContainerReady(body.accessToken, childId)) + ) + const failedChild = childResults.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ) + if (failedChild) { + throw failedChild.reason + } + + const parentBody: Record = { + media_type: 'CAROUSEL', + children: childIds.join(','), + } + if (body.caption) parentBody.caption = body.caption + + const containerId = await createMediaContainer(body.accessToken, igUserId, parentBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish carousel failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish carousel'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-image/route.ts b/apps/sim/app/api/tools/instagram/publish-image/route.ts new file mode 100644 index 00000000000..137c967e7a2 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-image/route.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishImageContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishImageAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish image: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishImageContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolved = await resolveInstagramMedia({ + input: body.image, + userId: authResult.userId, + requestId, + logger, + role: 'image', + label: 'Image', + }) + if (resolved.error || !resolved.media) { + return NextResponse.json( + { + success: false, + error: resolved.error?.message || 'Failed to resolve image', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolved.error?.status || 400 } + ) + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const containerBody: Record = { + image_url: resolved.media.url, + } + if (body.caption) containerBody.caption = body.caption + if (body.altText) containerBody.alt_text = body.altText + if (body.isAiGenerated === true) containerBody.is_ai_generated = true + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish image failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish image'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-reel/route.ts b/apps/sim/app/api/tools/instagram/publish-reel/route.ts new file mode 100644 index 00000000000..11f06f8720d --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-reel/route.ts @@ -0,0 +1,114 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishReelContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishReelAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish reel: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishReelContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolvedVideo = await resolveInstagramMedia({ + input: body.video, + userId: authResult.userId, + requestId, + logger, + role: 'video', + label: 'Video', + }) + if (resolvedVideo.error || !resolvedVideo.media) { + return NextResponse.json( + { + success: false, + error: resolvedVideo.error?.message || 'Failed to resolve video', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedVideo.error?.status || 400 } + ) + } + + let coverUrl: string | undefined + if (body.cover != null && body.cover !== '') { + const resolvedCover = await resolveInstagramMedia({ + input: body.cover, + userId: authResult.userId, + requestId, + logger, + role: 'cover', + required: false, + label: 'Cover image', + }) + if (resolvedCover.error) { + return NextResponse.json( + { + success: false, + error: resolvedCover.error.message, + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedCover.error.status } + ) + } + coverUrl = resolvedCover.media?.url + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const containerBody: Record = { + media_type: 'REELS', + video_url: resolvedVideo.media.url, + } + if (body.caption) containerBody.caption = body.caption + if (coverUrl) containerBody.cover_url = coverUrl + if (body.shareToFeed !== undefined && body.shareToFeed !== null) { + containerBody.share_to_feed = body.shareToFeed + } + if (body.thumbOffset != null) containerBody.thumb_offset = body.thumbOffset + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish reel failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish reel'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-story/route.ts b/apps/sim/app/api/tools/instagram/publish-story/route.ts new file mode 100644 index 00000000000..bc7ca629035 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-story/route.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishStoryContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishStoryAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish story: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishStoryContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolved = await resolveInstagramMedia({ + input: body.media, + userId: authResult.userId, + requestId, + logger, + role: 'story', + label: 'Story media', + }) + if (resolved.error || !resolved.media) { + return NextResponse.json( + { + success: false, + error: resolved.error?.message || 'Failed to resolve story media', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolved.error?.status || 400 } + ) + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + const containerBody: Record = { + media_type: 'STORIES', + } + if (resolved.media.kind === 'video') { + containerBody.video_url = resolved.media.url + } else { + containerBody.image_url = resolved.media.url + } + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish story failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish story'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-video/route.ts b/apps/sim/app/api/tools/instagram/publish-video/route.ts new file mode 100644 index 00000000000..4f4ca2cf224 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-video/route.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishVideoContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveInstagramMedia } from '@/lib/integrations/instagram/resolve-media' +import { + createMediaContainer, + publishMediaContainer, + resolveIgUserId, + waitForContainerReady, +} from '@/tools/instagram/utils' + +export const dynamic = 'force-dynamic' +/** Meta may poll container status once per minute for up to five minutes. */ +export const maxDuration = 600 + +const logger = createLogger('InstagramPublishVideoAPI') + +export const POST = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized Instagram publish video: ${authResult.error}`) + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(instagramPublishVideoContract, request, {}) + if (!parsed.success) return parsed.response + const body = parsed.data.body + + const resolvedVideo = await resolveInstagramMedia({ + input: body.video, + userId: authResult.userId, + requestId, + logger, + role: 'video', + label: 'Video', + }) + if (resolvedVideo.error || !resolvedVideo.media) { + return NextResponse.json( + { + success: false, + error: resolvedVideo.error?.message || 'Failed to resolve video', + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedVideo.error?.status || 400 } + ) + } + + let coverUrl: string | undefined + if (body.cover != null && body.cover !== '') { + const resolvedCover = await resolveInstagramMedia({ + input: body.cover, + userId: authResult.userId, + requestId, + logger, + role: 'cover', + required: false, + label: 'Cover image', + }) + if (resolvedCover.error) { + return NextResponse.json( + { + success: false, + error: resolvedCover.error.message, + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: resolvedCover.error.status } + ) + } + coverUrl = resolvedCover.media?.url + } + + const igUserId = await resolveIgUserId(body.accessToken, body.igUserId ?? undefined) + // Meta deprecated media_type=VIDEO for standalone posts; feed videos are REELS + share_to_feed. + const containerBody: Record = { + media_type: 'REELS', + video_url: resolvedVideo.media.url, + share_to_feed: true, + } + if (body.caption) containerBody.caption = body.caption + if (coverUrl) containerBody.cover_url = coverUrl + + const containerId = await createMediaContainer(body.accessToken, igUserId, containerBody) + const { statusCode } = await waitForContainerReady(body.accessToken, containerId) + const mediaId = await publishMediaContainer(body.accessToken, igUserId, containerId) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error(`[${requestId}] Instagram publish video failed:`, error) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish video'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts new file mode 100644 index 00000000000..75f97a8dd95 --- /dev/null +++ b/apps/sim/blocks/blocks/instagram.ts @@ -0,0 +1,887 @@ +import { InstagramIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' +import type { InstagramResponse } from '@/tools/instagram/types' + +/** + * Resolves a canonical media input to either an uploaded file object or a plain URL string. + * `normalizeFileInput` only recognizes file objects (or JSON-serialized file references) — a raw + * HTTPS URL typed into the advanced field is passed through as a string. + */ +function resolveSingleMediaInput(value: unknown): object | string | undefined { + const file = normalizeFileInput(value, { single: true }) + if (file) return file + if (typeof value === 'string' && value.trim() !== '') return value.trim() + return undefined +} + +/** + * Resolves carousel media to a file array, or a legacy comma-separated URL string. + */ +function resolveCarouselMediaInput(value: unknown): object[] | string | undefined { + if (typeof value === 'string') { + const trimmed = value.trim() + if (!trimmed) return undefined + try { + const parsed = JSON.parse(trimmed) as unknown + const files = normalizeFileInput(parsed) + if (files) return files + } catch { + // plain URL or comma-separated URLs + } + return trimmed + } + const files = normalizeFileInput(value) + if (files) return files + return undefined +} + +const IG_USER_ID_OPS = [ + 'instagram_list_media', + 'instagram_list_stories', + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_publishing_limit', + 'instagram_private_reply', + 'instagram_list_conversations', + 'instagram_send_text_message', + 'instagram_get_account_insights', +] + +export const InstagramBlock: BlockConfig = { + type: 'instagram', + name: 'Instagram', + description: 'Publish content, moderate comments, and manage Instagram DMs', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate Instagram into workflows. Publish images, videos, Reels, stories, and carousels; moderate comments; send DMs; and pull account or media insights.', + docsLink: 'https://docs.sim.ai/integrations/instagram', + category: 'tools', + integrationType: IntegrationType.Marketing, + // Gradient fills the tile; white camera glyph sits on top (same pattern as AWS icons). + bgColor: + 'radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)', + iconColor: '#E4405F', + icon: InstagramIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Get Profile', id: 'instagram_get_profile' }, + { label: 'List Media', id: 'instagram_list_media' }, + { label: 'Get Media', id: 'instagram_get_media' }, + { label: 'List Stories', id: 'instagram_list_stories' }, + { label: 'Publish Image', id: 'instagram_publish_image' }, + { label: 'Publish Video', id: 'instagram_publish_video' }, + { label: 'Publish Reel', id: 'instagram_publish_reel' }, + { label: 'Publish Story', id: 'instagram_publish_story' }, + { label: 'Publish Carousel', id: 'instagram_publish_carousel' }, + { label: 'Get Container Status', id: 'instagram_get_container_status' }, + { label: 'Get Publishing Limit', id: 'instagram_get_publishing_limit' }, + { label: 'List Comments', id: 'instagram_list_comments' }, + { label: 'Reply to Comment', id: 'instagram_reply_to_comment' }, + { label: 'Hide Comment', id: 'instagram_hide_comment' }, + { label: 'Delete Comment', id: 'instagram_delete_comment' }, + { label: 'Set Comments Enabled', id: 'instagram_set_comments_enabled' }, + { label: 'Private Reply', id: 'instagram_private_reply' }, + { label: 'List Conversations', id: 'instagram_list_conversations' }, + { label: 'Get Conversation Messages', id: 'instagram_get_conversation_messages' }, + { label: 'Get Message', id: 'instagram_get_message' }, + { label: 'Send Text Message', id: 'instagram_send_text_message' }, + { label: 'Get Account Insights', id: 'instagram_get_account_insights' }, + { label: 'Get Media Insights', id: 'instagram_get_media_insights' }, + ], + value: () => 'instagram_get_profile', + }, + + { + id: 'credential', + title: 'Instagram Account', + type: 'oauth-input', + serviceId: 'instagram', + canonicalParamId: 'oauthCredential', + mode: 'basic', + requiredScopes: getScopesForService('instagram'), + placeholder: 'Select Instagram account', + required: true, + }, + { + id: 'manualCredential', + title: 'Instagram Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + + // Publish Image — JPEG upload / file ref / 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: 'imageRef', + title: 'Image', + type: 'short-input', + 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_video', 'instagram_publish_reel'], + }, + mode: 'basic', + multiple: false, + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'videoRef', + title: 'Video', + type: 'short-input', + canonicalParamId: 'video', + placeholder: 'Reference files from previous blocks (or paste a public HTTPS video URL)', + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'advanced', + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'coverUpload', + title: 'Cover Image', + type: 'file-upload', + canonicalParamId: 'cover', + placeholder: 'Upload a JPEG cover image', + acceptedTypes: '.jpg,.jpeg,image/jpeg', + 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 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', + multiple: true, + required: { field: 'operation', value: 'instagram_publish_carousel' }, + }, + { + id: 'carouselMediaRef', + title: 'Media', + type: 'long-input', + 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, + prompt: `Generate a comma-separated list of public HTTPS media URLs for an Instagram carousel. +Use plain image URLs for photos. Prefix video URLs with "video:" (e.g. video:https://example.com/clip.mp4). +Between 2 and 10 items. Do not invent unreachable URLs — only use URLs the user provided or clearly implied. +Examples: +- "two photos" with urls A and B -> https://cdn.example/a.jpg,https://cdn.example/b.jpg +- "photo then video" -> https://cdn.example/a.jpg,video:https://cdn.example/b.mp4 + +Return ONLY the comma-separated URLs - no explanations, no extra text.`, + placeholder: 'Describe the carousel media URLs to include...', + }, + }, + + { + id: 'caption', + title: 'Caption', + type: 'long-input', + placeholder: 'Write a caption...', + condition: { + field: 'operation', + value: [ + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_carousel', + ], + }, + }, + { + id: 'altText', + title: 'Alt Text', + type: 'short-input', + placeholder: 'Accessibility description for the image', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'isAiGenerated', + title: 'AI Generated', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'shareToFeed', + title: 'Share to Feed', + type: 'dropdown', + options: [ + { 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,likes,comments,saves,shares,total_interactions +Media insights examples: views,reach,likes,comments,saved,shares,total_interactions +Use only valid Instagram Graph metric names. Prefer the smallest useful set. + +Return ONLY the comma-separated metric names - no explanations, no extra text.`, + placeholder: 'Describe which insight metrics you need...', + }, + }, + { + id: 'period', + title: 'Period', + type: 'dropdown', + options: [ + { label: 'Day', id: 'day' }, + { label: 'Week', id: 'week' }, + { label: '28 Days', id: 'days_28' }, + { label: 'Lifetime', id: 'lifetime' }, + { label: 'Total Over Range', id: 'total_over_range' }, + ], + value: () => 'day', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + required: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + { + id: 'since', + title: 'Since', + type: 'short-input', + placeholder: 'Unix timestamp or YYYY-MM-DD', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: `Generate a Unix timestamp in seconds (or YYYY-MM-DD) for the start of an Instagram insights range based on the user's description. +Examples: +- "7 days ago" -> Unix timestamp for 7 days ago at 00:00:00 UTC +- "start of last month" -> Unix timestamp for the first day of last month + +Return ONLY the timestamp or date - no explanations, no extra text.`, + placeholder: 'Describe the range start (e.g. "7 days ago")...', + }, + }, + { + id: 'until', + title: 'Until', + type: 'short-input', + placeholder: 'Unix timestamp or YYYY-MM-DD', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + wandConfig: { + enabled: true, + generationType: 'timestamp', + prompt: `Generate a Unix timestamp in seconds (or YYYY-MM-DD) for the end of an Instagram insights range based on the user's description. +Examples: +- "now" -> current Unix timestamp +- "end of yesterday" -> Unix timestamp for yesterday 23:59:59 UTC + +Return ONLY the timestamp or date - no explanations, no extra text.`, + placeholder: 'Describe the range end (e.g. "now")...', + }, + }, + { + id: 'metricType', + title: 'Metric Type', + type: 'dropdown', + options: [ + { label: 'Default', id: '' }, + { label: 'Time Series', id: 'time_series' }, + { label: 'Total Value', id: 'total_value' }, + ], + value: () => '', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + { + id: 'breakdown', + title: 'Breakdown', + type: 'short-input', + placeholder: 'Optional breakdown dimension', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + { + id: 'timeframe', + title: 'Demographic Timeframe', + type: 'dropdown', + options: [ + // Empty default keeps timeframe out of the request; it is only valid + // for demographic metrics (follower_demographics, engaged_audience_demographics). + { label: 'Default', id: '' }, + { label: 'This Week', id: 'this_week' }, + { label: 'This Month', id: 'this_month' }, + ], + value: () => '', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_get_account_insights' }, + }, + + { + id: 'limit', + title: 'Limit', + type: 'short-input', + placeholder: 'Max results (1-100, default 25)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['instagram_list_media', 'instagram_list_comments', 'instagram_list_conversations'], + }, + }, + { + id: 'after', + title: 'After Cursor', + type: 'short-input', + placeholder: 'Pagination cursor from a previous response', + mode: 'advanced', + condition: { + field: 'operation', + value: ['instagram_list_media', 'instagram_list_comments', 'instagram_list_conversations'], + }, + }, + { + id: 'igUserId', + title: 'Instagram User ID', + type: 'short-input', + placeholder: 'Optional IG professional account user id', + mode: 'advanced', + condition: { field: 'operation', value: IG_USER_ID_OPS }, + }, + ], + tools: { + access: [ + 'instagram_get_profile', + 'instagram_list_media', + 'instagram_get_media', + 'instagram_list_stories', + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_container_status', + 'instagram_get_publishing_limit', + 'instagram_list_comments', + 'instagram_reply_to_comment', + 'instagram_hide_comment', + 'instagram_delete_comment', + 'instagram_set_comments_enabled', + 'instagram_private_reply', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + 'instagram_get_message', + 'instagram_send_text_message', + 'instagram_get_account_insights', + 'instagram_get_media_insights', + ], + config: { + tool: (params) => params.operation, + params: (params) => { + const { oauthCredential, image, video, cover, media, carouselMedia, ...rest } = params + const result: Record = { + credential: oauthCredential, + } + + const operation = params.operation as string | undefined + + if (operation === 'instagram_publish_image') { + const resolved = resolveSingleMediaInput(image) + if (resolved) result.image = resolved + } else if ( + operation === 'instagram_publish_video' || + operation === 'instagram_publish_reel' + ) { + const resolvedVideo = resolveSingleMediaInput(video) + if (resolvedVideo) result.video = resolvedVideo + const resolvedCover = resolveSingleMediaInput(cover) + if (resolvedCover) result.cover = resolvedCover + } else if (operation === 'instagram_publish_story') { + const resolved = resolveSingleMediaInput(media) + if (resolved) result.media = resolved + } else if (operation === 'instagram_publish_carousel') { + // Distinct UI canonical (carouselMedia) so it doesn't collide with story's media pair + const resolved = resolveCarouselMediaInput(carouselMedia) + if (resolved) result.media = resolved + } + + for (const [key, value] of Object.entries(rest)) { + if (value === undefined || value === null || value === '') continue + if (key === 'operation' || key === 'credential' || key === 'manualCredential') continue + // Skip raw upload/ref subBlock ids — only canonical media params above + if ( + key === 'imageUpload' || + key === 'imageRef' || + key === 'videoUpload' || + key === 'videoRef' || + key === 'coverUpload' || + key === 'coverRef' || + key === 'storyMediaUpload' || + key === 'storyMediaRef' || + key === 'carouselMediaUpload' || + key === 'carouselMediaRef' + ) { + continue + } + + if (key === 'limit' || key === 'thumbOffset') { + result[key] = Number(value) + } else if ( + key === 'hide' || + key === 'commentEnabled' || + key === 'shareToFeed' || + key === 'isAiGenerated' + ) { + result[key] = value === true || value === 'true' + } else { + result[key] = value + } + } + + return result + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + oauthCredential: { type: 'string', description: 'Instagram OAuth credential' }, + image: { type: 'json', description: 'JPEG image file or public HTTPS URL for Publish Image' }, + video: { + type: 'json', + description: 'Video file or public HTTPS URL for Publish Video / Publish Reel', + }, + cover: { + type: 'json', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + media: { + type: 'json', + description: 'Story media: single JPEG image or MP4/MOV video file, or a public HTTPS URL', + }, + carouselMedia: { + type: 'json', + description: + 'Carousel media: 2-10 files, or comma-separated public HTTPS URLs (prefix videos with video:)', + }, + caption: { type: 'string', description: 'Post caption' }, + altText: { type: 'string', description: 'Image accessibility alt text' }, + isAiGenerated: { type: 'boolean', description: 'Whether the image is AI-generated' }, + shareToFeed: { type: 'boolean', description: 'Also share Reel to the main feed' }, + thumbOffset: { + type: 'number', + description: 'Video frame offset in milliseconds for the Reel cover thumbnail', + }, + mediaId: { type: 'string', description: 'Instagram media ID' }, + containerId: { type: 'string', description: 'Media container ID' }, + commentId: { type: 'string', description: 'Comment ID' }, + message: { type: 'string', description: 'Message or reply text' }, + hide: { type: 'boolean', description: 'Hide or unhide a comment' }, + commentEnabled: { type: 'boolean', description: 'Enable or disable comments on media' }, + conversationId: { type: 'string', description: 'DM conversation ID' }, + messageId: { type: 'string', description: 'DM message ID' }, + recipientId: { type: 'string', description: 'DM recipient Instagram-scoped ID' }, + metrics: { type: 'string', description: 'Comma-separated insight metrics' }, + period: { type: 'string', description: 'Insight aggregation period' }, + since: { type: 'string', description: 'Account insights range start' }, + until: { type: 'string', description: 'Account insights range end' }, + metricType: { type: 'string', description: 'Account insights metric_type' }, + breakdown: { type: 'string', description: 'Account insights breakdown dimension' }, + timeframe: { + type: 'string', + description: 'Lookback window required for demographic insight metrics', + }, + limit: { type: 'number', description: 'Maximum number of results' }, + after: { type: 'string', description: 'Pagination cursor' }, + igUserId: { type: 'string', description: 'Instagram professional account user ID' }, + }, + outputs: { + // Profile + userId: { type: 'string', description: 'Instagram professional account user_id' }, + id: { type: 'string', description: 'Graph object or message id' }, + username: { type: 'string', description: 'Instagram username' }, + name: { type: 'string', description: 'Display name' }, + accountType: { type: 'string', description: 'Business or Media_Creator' }, + profilePictureUrl: { type: 'string', description: 'Profile picture URL' }, + followersCount: { type: 'number', description: 'Follower count' }, + followsCount: { type: 'number', description: 'Following count' }, + mediaCount: { type: 'number', description: 'Media count' }, + // Media + media: { + type: 'json', + description: + 'List of media objects (id, caption, mediaType, mediaProductType, mediaUrl, permalink, timestamp, likeCount, commentsCount)', + }, + caption: { type: 'string', description: 'Media caption text' }, + mediaType: { type: 'string', description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM' }, + mediaProductType: { type: 'string', description: 'Feed, Reels, or Stories product type' }, + mediaUrl: { type: 'string', description: 'CDN media URL when available' }, + permalink: { type: 'string', description: 'Permalink to the post' }, + timestamp: { type: 'string', description: 'ISO timestamp' }, + likeCount: { type: 'number', description: 'Like count' }, + commentsCount: { type: 'number', description: 'Comments count' }, + children: { type: 'json', description: 'Carousel child media ids' }, + stories: { + type: 'json', + description: 'Active stories (id, mediaType, mediaUrl, timestamp)', + }, + // Publishing + containerId: { type: 'string', description: 'Media container id' }, + mediaId: { type: 'string', description: 'Published media id' }, + statusCode: { + type: 'string', + description: 'Container status (EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED)', + }, + status: { type: 'string', description: 'Detailed container status message' }, + quotaUsage: { type: 'number', description: 'Publishes used in the current window' }, + config: { type: 'json', description: 'Publishing quota config (quotaTotal, quotaDuration)' }, + // Comments + comments: { + type: 'json', + description: 'Comments (id, text, username, timestamp, likeCount, hidden)', + }, + success: { type: 'boolean', description: 'Whether the operation succeeded' }, + // Messaging + conversations: { type: 'json', description: 'Conversations (id, updatedTime)' }, + conversationId: { type: 'string', description: 'Conversation id' }, + messages: { + type: 'json', + description: + 'Message references (id, createdTime); use Get Message for sender, recipient, and text', + }, + createdTime: { type: 'string', description: 'Message created timestamp' }, + fromId: { type: 'string', description: 'Sender Instagram-scoped id' }, + fromUsername: { type: 'string', description: 'Sender username' }, + toId: { type: 'string', description: 'Recipient id' }, + message: { type: 'string', description: 'Message text' }, + messageId: { type: 'string', description: 'Sent or retrieved message id' }, + recipientId: { type: 'string', description: 'DM recipient id' }, + // Insights & pagination + insights: { + type: 'json', + description: 'Insight metrics (name, period, title, description, values, totalValue)', + }, + nextCursor: { type: 'string', description: 'Pagination cursor for the next page' }, + error: { type: 'string', description: 'Error message if the operation failed' }, + }, +} + +export const InstagramBlockMeta = { + tags: ['marketing', 'messaging', 'automation'], + url: 'https://www.instagram.com', + templates: [ + { + icon: InstagramIcon, + title: 'Instagram content publisher', + prompt: + 'Build a workflow that reads approved posts from a content table, publishes each as an Instagram image or Reel with caption, checks container status until finished, and writes the published media ID back to the row.', + modules: ['tables', 'scheduled', 'workflows'], + category: 'marketing', + tags: ['marketing', 'content', 'automation'], + }, + { + icon: InstagramIcon, + title: 'Instagram comment moderator', + prompt: + 'Create a scheduled workflow that lists recent Instagram media, pulls comments on each post, uses an agent to flag spam or abusive replies, and hides or deletes those comments while logging actions to a moderation table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'automation', 'moderation'], + }, + { + icon: InstagramIcon, + title: 'Instagram DM reply assistant', + prompt: + 'Build a workflow that lists Instagram Direct conversations, fetches recent message references and details, drafts helpful replies with an agent, and sends text messages back to the recipient while logging the thread to a support table.', + modules: ['tables', 'agent', 'workflows'], + category: 'support', + tags: ['messaging', 'support', 'automation'], + }, + { + icon: InstagramIcon, + title: 'Instagram insights digest', + prompt: + 'Create a scheduled weekly workflow that fetches Instagram account insights and media insights for top posts, summarizes performance with an agent, and writes a digest to a marketing table.', + modules: ['scheduled', 'tables', 'agent', 'workflows'], + category: 'marketing', + tags: ['marketing', 'analytics', 'automation'], + }, + ], + skills: [ + { + name: 'publish-instagram-image', + description: + 'Publish a JPEG image to Instagram with an optional caption and accessibility alt text.', + content: + '# Publish Instagram Image\n\nPublish a single image post to the connected Instagram professional account.\n\n## Steps\n1. Upload a JPEG image (or reference a file from a previous block / paste a public HTTPS JPEG URL) and draft a caption within Instagram length limits.\n2. Optionally set alt text for accessibility and mark the post as AI-generated when applicable.\n3. Run Publish Image, then optionally Get Container Status if you need to confirm publishing finished.\n\n## Output\nThe published media ID, container ID, status, and the final caption used.', + }, + { + name: 'moderate-instagram-comments', + description: + 'List comments on a post and hide, delete, or reply to ones that need moderation.', + content: + '# Moderate Instagram Comments\n\nReview and act on comments for a specific Instagram media object.\n\n## Steps\n1. List Comments for the target media ID and review text, username, and hidden state.\n2. Hide Comment or Delete Comment for spam or abusive replies; Reply to Comment for public responses; Private Reply when a DM follow-up is better.\n3. Optionally Set Comments Enabled to turn commenting off on the post.\n\n## Output\nA short summary of actions taken (hidden, deleted, replied) with comment IDs.', + }, + { + name: 'reply-instagram-dm', + description: 'Open an Instagram Direct conversation and send a text reply to the recipient.', + content: + '# Reply Instagram DM\n\nRespond to an Instagram Direct message thread.\n\n## Steps\n1. List Conversations to find the thread, then Get Conversation Messages for recent context.\n2. Optionally Get Message for a specific message ID if you need full details.\n3. Send Text Message to the recipient ID with a clear, helpful reply.\n\n## Output\nThe sent message ID, recipient ID, and the reply text used.', + }, + { + name: 'fetch-instagram-insights', + description: + 'Pull account-level or media-level Instagram insights for reporting and analysis.', + content: + '# Fetch Instagram Insights\n\nRetrieve performance metrics for the account or a specific post.\n\n## Steps\n1. For account interaction trends, run Get Account Insights with comma-separated metrics and the day period. Use lifetime plus a timeframe for demographic metrics.\n2. For a specific post, run Get Media Insights with the media ID and metrics like views, reach, likes, comments, saved, or shares.\n3. Summarize the returned insight values for the reporting window.\n\n## Output\nThe insights JSON plus a short plain-language summary of the key metrics.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 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 ( +export type InstagramPublishVideoBody = ContractBodyInput +export type InstagramPublishReelBody = ContractBodyInput +export type InstagramPublishStoryBody = ContractBodyInput +export type InstagramPublishCarouselBody = ContractBodyInput< + typeof instagramPublishCarouselContract +> + +export type InstagramPublishImageResponse = ContractJsonResponse< + typeof instagramPublishImageContract +> +export type InstagramPublishVideoResponse = ContractJsonResponse< + typeof instagramPublishVideoContract +> +export type InstagramPublishReelResponse = ContractJsonResponse +export type InstagramPublishStoryResponse = ContractJsonResponse< + typeof instagramPublishStoryContract +> +export type InstagramPublishCarouselResponse = ContractJsonResponse< + typeof instagramPublishCarouselContract +> diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 3919b0ba557..2dcaabe02a7 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -115,6 +115,12 @@ async function generateOAuthLink( if (providerId === 'trello') { return { url: `${baseUrl}/api/auth/trello/authorize`, providerId, serviceName } } + if (providerId === 'instagram') { + const authorizeUrl = new URL(`${baseUrl}/api/auth/instagram/authorize`) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + authorizeUrl.searchParams.set('workspaceId', workspaceId) + return { url: authorizeUrl.toString(), providerId, serviceName } + } if (providerId === 'shopify') { const returnUrl = encodeURIComponent(callbackURL) return { diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index d7903b9a380..3dbf1dc7aa2 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -387,6 +387,8 @@ export const env = createEnv({ TRELLO_API_KEY: z.string().optional(), // Trello API Key LINKEDIN_CLIENT_ID: z.string().optional(), // LinkedIn OAuth client ID LINKEDIN_CLIENT_SECRET: z.string().optional(), // LinkedIn OAuth client secret + INSTAGRAM_CLIENT_ID: z.string().optional(), // Instagram App ID (Business Login) + INSTAGRAM_CLIENT_SECRET: z.string().optional(), // Instagram App Secret (Business Login) SHOPIFY_CLIENT_ID: z.string().optional(), // Shopify OAuth client ID SHOPIFY_CLIENT_SECRET: z.string().optional(), // Shopify OAuth client secret ZOOM_CLIENT_ID: z.string().optional(), // Zoom OAuth client ID diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts new file mode 100644 index 00000000000..1c6d5ff2d87 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -0,0 +1,63 @@ +import { db } from '@sim/db' +import { pendingCredentialDraft, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, lt } from 'drizzle-orm' +import { getAllOAuthServices } from '@/lib/oauth/utils' + +const logger = createLogger('OAuthConnectDraft') +const DRAFT_TTL_MS = 15 * 60 * 1000 + +/** + * Creates the pending credential draft at OAuth click time so custom and + * generic OAuth callbacks can materialize the connected workspace credential. + */ +export async function createConnectDraft(params: { + userId: string + workspaceId: string + providerId: string +}): Promise { + const { userId, workspaceId, providerId } = params + const service = getAllOAuthServices().find((candidate) => candidate.providerId === providerId) + + let displayName = service?.name ?? providerId + try { + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (row?.name) { + displayName = `${row.name}'s ${displayName}` + } + } catch { + // Fall back to the service name. + } + + const now = new Date() + const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + + await db + .delete(pendingCredentialDraft) + .where( + and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) + ) + + await db + .insert(pendingCredentialDraft) + .values({ + id: generateId(), + userId, + workspaceId, + providerId, + displayName, + expiresAt, + createdAt: now, + }) + .onConflictDoUpdate({ + target: [ + pendingCredentialDraft.userId, + pendingCredentialDraft.providerId, + pendingCredentialDraft.workspaceId, + ], + set: { displayName, expiresAt, createdAt: now }, + }) + + logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) +} diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index e9df1348307..6672e8a47a4 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -109,6 +109,7 @@ import { IdentityCenterIcon, IncidentioIcon, InfisicalIcon, + InstagramIcon, InstantlyIcon, IntercomIcon, JinaAIIcon, @@ -344,6 +345,7 @@ export const blockTypeToIconMap: Record = { imap: MailServerIcon, incidentio: IncidentioIcon, infisical: InfisicalIcon, + instagram: InstagramIcon, instantly: InstantlyIcon, intercom_v2: IntercomIcon, jina: JinaAIIcon, diff --git a/apps/sim/lib/integrations/instagram.ts b/apps/sim/lib/integrations/instagram.ts new file mode 100644 index 00000000000..cc641f5584f --- /dev/null +++ b/apps/sim/lib/integrations/instagram.ts @@ -0,0 +1,2 @@ +export const INSTAGRAM_GRAPH_VERSION = 'v22.0' +export const INSTAGRAM_GRAPH_BASE = `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}` diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/lib/integrations/instagram/resolve-media.ts new file mode 100644 index 00000000000..139068d7fce --- /dev/null +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -0,0 +1,376 @@ +import type { Logger } from '@sim/logger' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { + getFileExtension, + getMimeTypeFromExtension, + isInternalFileUrl, + type RawFileInput, + resolveFileType, +} from '@/lib/uploads/utils/file-utils' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' + +/** Covers Meta's poll-once-per-minute for ≤5 minutes while the container processes. */ +export const INSTAGRAM_MEDIA_URL_TTL_SECONDS = 600 + +const IMAGE_MAX_BYTES = 8 * 1024 * 1024 +const REEL_VIDEO_MAX_BYTES = 300 * 1024 * 1024 +const STORY_VIDEO_MAX_BYTES = 100 * 1024 * 1024 + +const JPEG_MIME = new Set(['image/jpeg', 'image/jpg']) +const VIDEO_MIME = new Set(['video/mp4', 'video/quicktime']) +const JPEG_EXT = new Set(['jpg', 'jpeg']) +const VIDEO_EXT = new Set(['mp4', 'mov']) + +const CLOUD_STORAGE_REQUIRED_MESSAGE = + 'Cloud storage is required to publish uploaded Instagram media. Configure S3 or Blob storage, or paste a public HTTPS URL instead.' + +export type InstagramMediaRole = 'image' | 'video' | 'cover' | 'story' | 'carousel' + +export interface ResolvedInstagramMedia { + url: string + kind: 'image' | 'video' + mimeType?: string + size?: number + name?: string +} + +export interface ResolveInstagramMediaResult { + media?: ResolvedInstagramMedia + error?: { status: number; message: string } +} + +export interface ResolveInstagramMediaOptions { + input: unknown + userId: string + requestId: string + logger: Logger + role: InstagramMediaRole + required?: boolean + label?: string +} + +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}MB` + return `${bytes} bytes` +} + +function extensionFromUrlOrName(value?: string): string { + if (!value) return '' + try { + const pathname = + value.startsWith('http://') || value.startsWith('https://') ? new URL(value).pathname : value + return getFileExtension(pathname.split('?')[0] || '') + } catch { + return getFileExtension(value) + } +} + +function inferKindFromMimeOrExt( + mimeType: string | undefined, + extension: string +): 'image' | 'video' | null { + const mime = (mimeType || '').toLowerCase() + if (mime.startsWith('image/') || JPEG_EXT.has(extension)) return 'image' + if (mime.startsWith('video/') || VIDEO_EXT.has(extension)) return 'video' + return null +} + +function validateMediaConstraints( + kind: 'image' | 'video', + role: InstagramMediaRole, + mimeType: string | undefined, + size: number | undefined, + label: string +): string | null { + const mime = (mimeType || '').toLowerCase() + + if (kind === 'image') { + if (mime && !JPEG_MIME.has(mime)) { + return `${label} must be a JPEG image (got ${mime})` + } + if (size != null && size > IMAGE_MAX_BYTES) { + return `${label} exceeds Instagram's ${formatBytes(IMAGE_MAX_BYTES)} JPEG limit (got ${formatBytes(size)})` + } + return null + } + + if (mime && !VIDEO_MIME.has(mime)) { + return `${label} must be an MP4 or MOV video (got ${mime})` + } + + const maxBytes = role === 'story' ? STORY_VIDEO_MAX_BYTES : REEL_VIDEO_MAX_BYTES + if (size != null && size > maxBytes) { + return `${label} exceeds Instagram's ${formatBytes(maxBytes)} video limit for ${role} (got ${formatBytes(size)})` + } + return null +} + +function isPublicHttpUrl(value: string): boolean { + return (value.startsWith('https://') || value.startsWith('http://')) && !isInternalFileUrl(value) +} + +function needsCloudStorage(file?: RawFileInput, filePath?: string): boolean { + if (file) return true + if (filePath && isInternalFileUrl(filePath)) return true + return false +} + +/** + * Split a canonical media input into the shapes {@link resolveFileInputToUrl} expects + * (Reducto/STT pattern: file object vs filePath string). + */ +function splitMediaInput(input: unknown): { + file?: RawFileInput + filePath?: string + name?: string + size?: number + mimeType?: string + error?: { status: number; message: string } +} { + if (typeof input === 'string') { + return { filePath: input.trim() } + } + + if (typeof input === 'object' && input !== null && !Array.isArray(input)) { + const raw = input as RawFileInput + if (!raw.name) { + return { error: { status: 400, message: 'File must include a name' } } + } + return { + file: raw, + name: raw.name, + size: typeof raw.size === 'number' ? raw.size : undefined, + mimeType: resolveFileType({ type: raw.type || '', name: raw.name }), + } + } + + return { error: { status: 400, message: 'Media must be a file or URL string' } } +} + +function applyInstagramConstraints( + url: string, + role: InstagramMediaRole, + label: string, + meta: { name?: string; size?: number; mimeType?: string } +): ResolveInstagramMediaResult { + const mimeType = + meta.mimeType || getMimeTypeFromExtension(extensionFromUrlOrName(meta.name || url)) + const extension = extensionFromUrlOrName(meta.name || url) + + let kind: 'image' | 'video' + if (role === 'image' || role === 'cover') { + kind = 'image' + } else if (role === 'video') { + kind = 'video' + } else { + const inferred = inferKindFromMimeOrExt(mimeType, extension) + if (!inferred) { + return { + error: { + status: 400, + message: `${label} must be a JPEG image or MP4/MOV video`, + }, + } + } + kind = inferred + } + + if ( + (role === 'story' || role === 'carousel') && + (!mimeType || mimeType === 'application/octet-stream') + ) { + if (JPEG_EXT.has(extension)) kind = 'image' + else if (VIDEO_EXT.has(extension)) kind = 'video' + } + + const constraintError = validateMediaConstraints( + kind, + role, + mimeType === 'application/octet-stream' ? undefined : mimeType, + meta.size, + label + ) + if (constraintError) { + return { error: { status: 400, message: constraintError } } + } + + if (kind === 'image' && extension && !JPEG_EXT.has(extension) && !mimeType) { + return { + error: { status: 400, message: `${label} must be a JPEG (.jpg/.jpeg)` }, + } + } + if (kind === 'video' && extension && !VIDEO_EXT.has(extension) && !mimeType) { + return { + error: { status: 400, message: `${label} must be an MP4 or MOV video` }, + } + } + + return { + media: { + url, + kind, + mimeType, + size: meta.size, + name: meta.name, + }, + } +} + +/** + * Resolve a UserFile, internal serve path, or public HTTPS URL into a Meta-fetchable HTTPS URL. + * Delegates UserFile serialization and URL minting to {@link resolveFileInputToUrl} + * (600s TTL, prefer key presign). Instagram-specific MIME/size checks stay here. + */ +export async function resolveInstagramMedia( + options: ResolveInstagramMediaOptions +): Promise { + const { input, userId, requestId, logger, role, required = true, label = 'Media' } = options + + if (input == null || input === '') { + if (required) { + return { error: { status: 400, message: `${label} is required` } } + } + return {} + } + + const split = splitMediaInput(input) + if (split.error) { + return { error: split.error } + } + + const { file, filePath, name, size, mimeType } = split + if (filePath !== undefined && filePath === '') { + if (required) { + return { error: { status: 400, message: `${label} is required` } } + } + return {} + } + + if (needsCloudStorage(file, filePath) && !hasCloudStorage()) { + return { error: { status: 400, message: CLOUD_STORAGE_REQUIRED_MESSAGE } } + } + + // Meta only curls HTTPS; reject plain HTTP before the shared helper accepts it. + if (filePath && isPublicHttpUrl(filePath) && !filePath.startsWith('https://')) { + return { + error: { + status: 400, + message: 'Instagram media URLs must use HTTPS so Meta can download them', + }, + } + } + + const resolution = await resolveFileInputToUrl({ + file, + filePath, + userId, + requestId, + logger, + ttlSeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS, + preferKeyPresign: true, + }) + + if (resolution.error || !resolution.fileUrl) { + return { + error: resolution.error || { status: 400, message: `${label} is required` }, + } + } + + return applyInstagramConstraints(resolution.fileUrl, role, label, { + name, + size, + mimeType, + }) +} + +/** + * Resolve carousel media: file array, single file, or legacy comma-separated URL string + * (optional `video:` prefix per entry). + */ +export async function resolveInstagramCarouselMedia( + input: unknown, + userId: string, + requestId: string, + logger: Logger +): Promise<{ items?: ResolvedInstagramMedia[]; error?: { status: number; message: string } }> { + if (input == null || input === '') { + return { error: { status: 400, message: 'Carousel media is required' } } + } + + const items: ResolvedInstagramMedia[] = [] + + if (typeof input === 'string') { + const trimmed = input.trim() + if (!trimmed) { + return { error: { status: 400, message: 'Carousel media is required' } } + } + + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as unknown + return resolveInstagramCarouselMedia(parsed, userId, requestId, logger) + } catch { + // fall through to comma-separated URL parsing + } + } + + const entries = trimmed + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + + if (entries.length < 2 || entries.length > 10) { + return { error: { status: 400, message: 'Carousels require between 2 and 10 items' } } + } + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + const isVideoPrefixed = entry.toLowerCase().startsWith('video:') + const raw = isVideoPrefixed ? entry.slice('video:'.length).trim() : entry + const result = await resolveInstagramMedia({ + input: raw, + userId, + requestId, + logger, + role: isVideoPrefixed ? 'video' : 'image', + label: `Carousel item ${i + 1}`, + }) + if (result.error || !result.media) { + return { + error: result.error || { + status: 400, + message: `Failed to resolve carousel item ${i + 1}`, + }, + } + } + result.media.kind = isVideoPrefixed ? 'video' : 'image' + items.push(result.media) + } + + return { items } + } + + const list = Array.isArray(input) ? input : [input] + if (list.length < 2 || list.length > 10) { + return { error: { status: 400, message: 'Carousels require between 2 and 10 items' } } + } + + for (let i = 0; i < list.length; i++) { + const result = await resolveInstagramMedia({ + input: list[i], + userId, + requestId, + logger, + role: 'carousel', + label: `Carousel item ${i + 1}`, + }) + if (result.error || !result.media) { + return { + error: result.error || { status: 400, message: `Failed to resolve carousel item ${i + 1}` }, + } + } + items.push(result.media) + } + + return { items } +} diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 9d543582283..880a4802bc4 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-10", "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 an uploaded file or public HTTPS URL (polls until the container is ready)" + }, + { + "name": "Publish Video", + "description": "Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready)" + }, + { + "name": "Publish Reel", + "description": "Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready)" + }, + { + "name": "Publish Story", + "description": "Publish an image or video story from an uploaded file or public HTTPS URL" + }, + { + "name": "Publish Carousel", + "description": "Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs" + }, + { + "name": "Get Container Status", + "description": "Check the publishing status of a media container" + }, + { + "name": "Get Publishing Limit", + "description": "Check the content publishing rate limit usage for the account" + }, + { + "name": "List Comments", + "description": "List comments on an Instagram media object" + }, + { + "name": "Reply to Comment", + "description": "Reply to a comment on Instagram media" + }, + { + "name": "Hide Comment", + "description": "Hide or unhide a comment on Instagram media" + }, + { + "name": "Delete Comment", + "description": "Delete a comment on Instagram media" + }, + { + "name": "Set Comments Enabled", + "description": "Enable or disable comments on an Instagram media object" + }, + { + "name": "Private Reply", + "description": "Send a private Direct message reply to a commenter (one per commenter)" + }, + { + "name": "List Conversations", + "description": "List Instagram Direct conversations for the professional account" + }, + { + "name": "Get Conversation Messages", + "description": "List recent message ids/content in a conversation (only the last ~20 messages are fetchable)" + }, + { + "name": "Get Message", + "description": "Get a single Instagram Direct message by id (only recent messages are available)" + }, + { + "name": "Send Text Message", + "description": "Send a text Direct message. The recipient must have messaged the account first (24h window)." + }, + { + "name": "Get Account Insights", + "description": "Get insights metrics for the Instagram professional account" + }, + { + "name": "Get Media Insights", + "description": "Get insights metrics for a specific Instagram media object" + } + ], + "operationCount": 23, + "triggers": [], + "triggerCount": 0, + "authType": "oauth", + "oauthServiceId": "instagram", + "category": "tools", + "integrationType": "marketing", + "tags": ["marketing", "messaging", "automation"] + }, { "type": "instantly", "slug": "instantly", diff --git a/apps/sim/lib/integrations/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 4cdf616ae4a..3e7614acc5d 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -43,6 +43,7 @@ const EXPECTED_PROVIDER_BY_SLUG: Record = { jira: 'jira', 'jira-service-management': 'jira', linear: 'linear', + instagram: 'instagram', linkedin: 'linkedin', 'microsoft-dataverse': 'microsoft-dataverse', 'microsoft-excel': 'microsoft-excel', diff --git a/apps/sim/lib/oauth/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/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 1077512cc97..330756f8166 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -40,6 +40,9 @@ export interface FileResolutionResult { } } +/** Default presigned download URL lifetime (5 minutes). */ +const DEFAULT_PRESIGNED_DOWNLOAD_TTL_SECONDS = 5 * 60 + /** * Options for resolving file input to a URL */ @@ -49,6 +52,46 @@ export interface ResolveFileInputOptions { userId: string requestId: string logger: Logger + /** + * Presigned download URL TTL in seconds. Defaults to 5 minutes. + */ + ttlSeconds?: number + /** + * When true and the file has a storage key, always mint a fresh presigned URL + * from the key (instead of reusing an existing non-internal URL). Use this when + * a third party must fetch the file over a longer window (e.g. Instagram/Meta). + */ + preferKeyPresign?: boolean +} + +async function presignFromStorageKey( + key: string, + contextHint: string | undefined, + userId: string, + requestId: string, + logger: Logger, + ttlSeconds: number +): Promise { + const context = resolveTrustedFileContext(key, contextHint) + const hasAccess = await verifyFileAccess(key, userId, undefined, context, false) + + if (!hasAccess) { + logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { + userId, + key, + context, + }) + return { error: { status: 404, message: 'File not found' } } + } + + try { + const fileUrl = await StorageService.generatePresignedDownloadUrl(key, context, ttlSeconds) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`, { ttlSeconds }) + return { fileUrl } + } catch (error) { + logger.error(`[${requestId}] Failed to generate presigned URL:`, error) + return { error: { status: 500, message: 'Failed to generate file access URL' } } + } } /** @@ -62,7 +105,15 @@ export interface ResolveFileInputOptions { export async function resolveFileInputToUrl( options: ResolveFileInputOptions ): Promise { - const { file, filePath, userId, requestId, logger } = options + const { + file, + filePath, + userId, + requestId, + logger, + ttlSeconds = DEFAULT_PRESIGNED_DOWNLOAD_TTL_SECONDS, + preferKeyPresign = false, + } = options if (file) { let userFile: UserFile @@ -77,11 +128,28 @@ export async function resolveFileInputToUrl( } } + if (preferKeyPresign && userFile.key) { + return presignFromStorageKey( + userFile.key, + userFile.context, + userId, + requestId, + logger, + ttlSeconds + ) + } + let fileUrl = userFile.url || '' // Handle internal URLs if (fileUrl && isInternalFileUrl(fileUrl)) { - const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger) + const resolution = await resolveInternalFileUrl( + fileUrl, + userId, + requestId, + logger, + ttlSeconds + ) if (resolution.error) { return { error: resolution.error } } @@ -90,19 +158,14 @@ export async function resolveFileInputToUrl( // Generate presigned URL if we have a key but no URL if (!fileUrl && userFile.key) { - const context = resolveTrustedFileContext(userFile.key, userFile.context) - const hasAccess = await verifyFileAccess(userFile.key, userId, undefined, context, false) - - if (!hasAccess) { - logger.warn(`[${requestId}] Unauthorized presigned URL generation attempt`, { - userId, - key: userFile.key, - context, - }) - return { error: { status: 404, message: 'File not found' } } - } - - fileUrl = await StorageService.generatePresignedDownloadUrl(userFile.key, context, 5 * 60) + return presignFromStorageKey( + userFile.key, + userFile.context, + userId, + requestId, + logger, + ttlSeconds + ) } return { fileUrl } @@ -112,7 +175,13 @@ export async function resolveFileInputToUrl( let fileUrl = filePath if (isInternalFileUrl(filePath)) { - const resolution = await resolveInternalFileUrl(filePath, userId, requestId, logger) + const resolution = await resolveInternalFileUrl( + filePath, + userId, + requestId, + logger, + ttlSeconds + ) if (resolution.error) { return { error: resolution.error } } @@ -227,7 +296,8 @@ export async function resolveInternalFileUrl( filePath: string, userId: string, requestId: string, - logger: Logger + logger: Logger, + ttlSeconds: number = DEFAULT_PRESIGNED_DOWNLOAD_TTL_SECONDS ): Promise<{ fileUrl?: string; error?: { status: number; message: string } }> { if (!isInternalFileUrl(filePath)) { return { fileUrl: filePath } @@ -247,8 +317,12 @@ export async function resolveInternalFileUrl( return { error: { status: 404, message: 'File not found' } } } - const fileUrl = await StorageService.generatePresignedDownloadUrl(storageKey, context, 5 * 60) - logger.info(`[${requestId}] Generated presigned URL for ${context} file`) + const fileUrl = await StorageService.generatePresignedDownloadUrl( + storageKey, + context, + ttlSeconds + ) + logger.info(`[${requestId}] Generated presigned URL for ${context} file`, { ttlSeconds }) return { fileUrl } } catch (error) { logger.error(`[${requestId}] Failed to generate presigned URL:`, error) diff --git a/apps/sim/tools/instagram/delete_comment.ts b/apps/sim/tools/instagram/delete_comment.ts new file mode 100644 index 00000000000..33bb7e84dfe --- /dev/null +++ b/apps/sim/tools/instagram/delete_comment.ts @@ -0,0 +1,62 @@ +import type { + InstagramDeleteCommentParams, + InstagramDeleteCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramDeleteCommentTool: ToolConfig< + InstagramDeleteCommentParams, + InstagramDeleteCommentResponse +> = { + id: 'instagram_delete_comment', + name: 'Instagram Delete Comment', + description: 'Delete a comment on Instagram media', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to delete', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.commentId.trim()}`), + method: 'DELETE', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await readGraphError(response), + } + } + + const data = await response.json().catch(() => ({ success: true })) + return { + success: true, + output: { success: data.success !== false }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the delete succeeded' }, + }, +} diff --git a/apps/sim/tools/instagram/get_account_insights.ts b/apps/sim/tools/instagram/get_account_insights.ts new file mode 100644 index 00000000000..3af2b77cab4 --- /dev/null +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -0,0 +1,135 @@ +import type { + InstagramGetAccountInsightsParams, + InstagramGetAccountInsightsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + parseCommaSeparated, + readGraphError, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetAccountInsightsTool: ToolConfig< + InstagramGetAccountInsightsParams, + InstagramGetAccountInsightsResponse +> = { + id: 'instagram_get_account_insights', + name: 'Instagram Get Account Insights', + description: 'Get insights metrics for the Instagram professional account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated metrics (e.g. reach,views,accounts_engaged,profile_views,follower_count)', + }, + period: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Aggregation period: day, week, days_28, lifetime, or total_over_range', + }, + since: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unix timestamp or date for range start', + }, + until: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Unix timestamp or date for range end', + }, + metricType: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional metric_type (e.g. time_series, total_value)', + }, + breakdown: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional breakdown dimension', + }, + timeframe: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Required lookback window for demographic metrics', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/insights` : '/me/insights' + return graphUrl(path, { + metric: parseCommaSeparated(params.metrics).join(','), + period: params.period.trim(), + since: params.since?.trim() || undefined, + until: params.until?.trim() || undefined, + metric_type: params.metricType?.trim() || undefined, + breakdown: params.breakdown?.trim() || undefined, + timeframe: params.timeframe?.trim() || undefined, + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { insights: [] }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + insights: items.map((item: Record) => ({ + name: (item.name as string | undefined) ?? null, + period: (item.period as string | undefined) ?? null, + title: (item.title as string | undefined) ?? null, + description: (item.description as string | undefined) ?? null, + values: Array.isArray(item.values) ? item.values : [], + totalValue: item.total_value ?? null, + })), + }, + } + }, + + outputs: { + insights: { + type: 'json', + description: 'Insight metrics (name, period, title, description, values, totalValue)', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_container_status.ts b/apps/sim/tools/instagram/get_container_status.ts new file mode 100644 index 00000000000..43f62f8ee36 --- /dev/null +++ b/apps/sim/tools/instagram/get_container_status.ts @@ -0,0 +1,80 @@ +import type { + InstagramGetContainerStatusParams, + InstagramGetContainerStatusResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetContainerStatusTool: ToolConfig< + InstagramGetContainerStatusParams, + InstagramGetContainerStatusResponse +> = { + id: 'instagram_get_container_status', + name: 'Instagram Get Container Status', + description: 'Check the publishing status of a media container', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + containerId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Media container id returned from a create/publish step', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.containerId.trim()}`, { fields: 'status_code,status' }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response, params): Promise => { + if (!response.ok) { + return { + success: false, + output: { + containerId: params?.containerId ?? '', + statusCode: null, + status: null, + }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { + containerId: params?.containerId ?? idString(data.id) ?? '', + statusCode: data.status_code ?? null, + status: data.status ?? null, + }, + } + }, + + outputs: { + containerId: { type: 'string', description: 'Container id' }, + statusCode: { + type: 'string', + description: 'EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED', + optional: true, + }, + status: { + type: 'string', + description: 'Detailed status message when available', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/get_conversation_messages.ts b/apps/sim/tools/instagram/get_conversation_messages.ts new file mode 100644 index 00000000000..817873fd71c --- /dev/null +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -0,0 +1,82 @@ +import type { + InstagramGetConversationMessagesParams, + InstagramGetConversationMessagesResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetConversationMessagesTool: ToolConfig< + InstagramGetConversationMessagesParams, + InstagramGetConversationMessagesResponse +> = { + id: 'instagram_get_conversation_messages', + name: 'Instagram Get Conversation Messages', + description: + 'List recent message ids/content in a conversation (only the last ~20 messages are fetchable)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + conversationId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Conversation id from list_conversations', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.conversationId.trim()}`, { + fields: 'messages{id,created_time}', + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async ( + response, + params + ): Promise => { + if (!response.ok) { + return { + success: false, + output: { conversationId: params?.conversationId ?? '', messages: [] }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.messages?.data) ? data.messages.data : [] + + return { + success: true, + output: { + conversationId: params?.conversationId ?? idString(data.id) ?? '', + messages: items.map((item: Record) => ({ + id: String(item.id ?? ''), + createdTime: (item.created_time as string | undefined) ?? null, + })), + }, + } + }, + + outputs: { + conversationId: { type: 'string', description: 'Conversation id' }, + messages: { + type: 'json', + description: + 'Message references (id, createdTime). Use Get Message for sender, recipient, and text.', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts new file mode 100644 index 00000000000..954a883b7f5 --- /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, idString, 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: idString(data.id), + caption: data.caption ?? null, + mediaType: data.media_type ?? null, + mediaProductType: data.media_product_type ?? null, + mediaUrl: data.media_url ?? null, + permalink: data.permalink ?? null, + timestamp: data.timestamp ?? null, + likeCount: data.like_count ?? null, + commentsCount: data.comments_count ?? null, + children, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Media id', optional: true }, + caption: { type: 'string', description: 'Caption text', optional: true }, + mediaType: { type: 'string', description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM', optional: true }, + mediaProductType: { + type: 'string', + description: 'Feed, Reels, or Stories product type', + optional: true, + }, + mediaUrl: { type: 'string', description: 'CDN media URL when available', optional: true }, + permalink: { type: 'string', description: 'Permalink to the post', optional: true }, + timestamp: { type: 'string', description: 'ISO timestamp', optional: true }, + likeCount: { type: 'number', description: 'Like count', optional: true }, + commentsCount: { type: 'number', description: 'Comments count', optional: true }, + children: { type: 'json', description: 'Carousel child media ids' }, + }, + } diff --git a/apps/sim/tools/instagram/get_media_insights.ts b/apps/sim/tools/instagram/get_media_insights.ts new file mode 100644 index 00000000000..b4f417dd81f --- /dev/null +++ b/apps/sim/tools/instagram/get_media_insights.ts @@ -0,0 +1,91 @@ +import type { + InstagramGetMediaInsightsParams, + InstagramGetMediaInsightsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + parseCommaSeparated, + readGraphError, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetMediaInsightsTool: ToolConfig< + InstagramGetMediaInsightsParams, + InstagramGetMediaInsightsResponse +> = { + id: 'instagram_get_media_insights', + name: 'Instagram Get Media Insights', + description: 'Get insights metrics for a specific Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + metrics: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Comma-separated metrics (e.g. views,reach,likes,comments,saved,shares,total_interactions)', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.mediaId.trim()}/insights`, { + metric: parseCommaSeparated(params.metrics).join(','), + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { insights: [] }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + insights: items.map((item: Record) => ({ + name: (item.name as string | undefined) ?? null, + period: (item.period as string | undefined) ?? null, + title: (item.title as string | undefined) ?? null, + description: (item.description as string | undefined) ?? null, + values: Array.isArray(item.values) ? item.values : [], + totalValue: item.total_value ?? null, + })), + }, + } + }, + + outputs: { + insights: { + type: 'json', + description: 'Media insight metrics (name, period, title, description, values, totalValue)', + }, + }, +} diff --git a/apps/sim/tools/instagram/get_message.ts b/apps/sim/tools/instagram/get_message.ts new file mode 100644 index 00000000000..164b1d44c01 --- /dev/null +++ b/apps/sim/tools/instagram/get_message.ts @@ -0,0 +1,88 @@ +import type { + InstagramGetMessageParams, + InstagramGetMessageResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetMessageTool: ToolConfig< + InstagramGetMessageParams, + InstagramGetMessageResponse +> = { + id: 'instagram_get_message', + name: 'Instagram Get Message', + description: 'Get a single Instagram Direct message by id (only recent messages are available)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message id', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.messageId.trim()}`, { + fields: 'id,created_time,from,to,message', + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { + id: null, + createdTime: null, + fromId: null, + fromUsername: null, + toId: null, + message: null, + }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const from = data.from as { id?: string | number; username?: string } | undefined + const toData = data.to?.data + const toFirst = Array.isArray(toData) ? toData[0] : undefined + + return { + success: true, + output: { + id: idString(data.id), + createdTime: data.created_time ?? null, + fromId: idString(from?.id), + fromUsername: from?.username ?? null, + toId: idString(toFirst?.id), + message: data.message ?? null, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Message id', optional: true }, + createdTime: { type: 'string', description: 'Created timestamp', optional: true }, + fromId: { type: 'string', description: 'Sender Instagram-scoped id', optional: true }, + fromUsername: { type: 'string', description: 'Sender username', optional: true }, + toId: { type: 'string', description: 'Recipient id', optional: true }, + message: { type: 'string', description: 'Message text', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/get_profile.ts b/apps/sim/tools/instagram/get_profile.ts new file mode 100644 index 00000000000..11b9b43f28f --- /dev/null +++ b/apps/sim/tools/instagram/get_profile.ts @@ -0,0 +1,92 @@ +import type { + InstagramGetProfileParams, + InstagramGetProfileResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const PROFILE_FIELDS = + 'user_id,id,username,name,account_type,profile_picture_url,followers_count,follows_count,media_count' + +export const instagramGetProfileTool: ToolConfig< + InstagramGetProfileParams, + InstagramGetProfileResponse +> = { + id: 'instagram_get_profile', + name: 'Instagram Get Profile', + description: 'Get the connected Instagram professional account profile', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + }, + + request: { + url: () => graphUrl('/me', { fields: PROFILE_FIELDS }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { + userId: null, + id: null, + username: null, + name: null, + accountType: null, + profilePictureUrl: null, + followersCount: null, + followsCount: null, + mediaCount: null, + }, + error: await readGraphError(response), + } + } + + const data = await response.json() + + return { + success: true, + output: { + userId: idString(data.user_id), + id: idString(data.id), + username: data.username ?? null, + name: data.name ?? null, + accountType: data.account_type ?? null, + profilePictureUrl: data.profile_picture_url ?? null, + followersCount: data.followers_count ?? null, + followsCount: data.follows_count ?? null, + mediaCount: data.media_count ?? null, + }, + } + }, + + outputs: { + userId: { + type: 'string', + description: 'Instagram professional account user_id', + optional: true, + }, + id: { type: 'string', description: 'Graph object id', optional: true }, + username: { type: 'string', description: 'Instagram username', optional: true }, + name: { type: 'string', description: 'Display name', optional: true }, + accountType: { type: 'string', description: 'Business or Media_Creator', optional: true }, + profilePictureUrl: { type: 'string', description: 'Profile picture URL', optional: true }, + followersCount: { type: 'number', description: 'Follower count', optional: true }, + followsCount: { type: 'number', description: 'Following count', optional: true }, + mediaCount: { type: 'number', description: 'Media count', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/get_publishing_limit.ts b/apps/sim/tools/instagram/get_publishing_limit.ts new file mode 100644 index 00000000000..c836b1539fb --- /dev/null +++ b/apps/sim/tools/instagram/get_publishing_limit.ts @@ -0,0 +1,86 @@ +import type { + InstagramGetPublishingLimitParams, + InstagramGetPublishingLimitResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramGetPublishingLimitTool: ToolConfig< + InstagramGetPublishingLimitParams, + InstagramGetPublishingLimitResponse +> = { + id: 'instagram_get_publishing_limit', + name: 'Instagram Get Publishing Limit', + description: 'Check the content publishing rate limit usage for the account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() + ? `/${params.igUserId.trim()}/content_publishing_limit` + : '/me/content_publishing_limit' + return graphUrl(path, { fields: 'quota_usage,config' }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { quotaUsage: null, config: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const first = Array.isArray(data.data) ? data.data[0] : data + + return { + success: true, + output: { + quotaUsage: first?.quota_usage ?? null, + config: first?.config + ? { + quotaTotal: first.config.quota_total ?? null, + quotaDuration: first.config.quota_duration ?? null, + } + : null, + }, + } + }, + + outputs: { + quotaUsage: { + type: 'number', + description: 'Number of publishes used in the current window', + optional: true, + }, + config: { + type: 'json', + description: 'Quota config (quotaTotal, quotaDuration)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/hide_comment.ts b/apps/sim/tools/instagram/hide_comment.ts new file mode 100644 index 00000000000..244ee806409 --- /dev/null +++ b/apps/sim/tools/instagram/hide_comment.ts @@ -0,0 +1,68 @@ +import type { + InstagramHideCommentParams, + InstagramHideCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramHideCommentTool: ToolConfig< + InstagramHideCommentParams, + InstagramHideCommentResponse +> = { + id: 'instagram_hide_comment', + name: 'Instagram Hide Comment', + description: 'Hide or unhide a comment on Instagram media', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id', + }, + hide: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'True to hide, false to unhide', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.commentId.trim()}`, { hide: String(params.hide) }), + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { success: data.success === true || data.success === undefined }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the hide/unhide succeeded' }, + }, +} diff --git a/apps/sim/tools/instagram/index.ts b/apps/sim/tools/instagram/index.ts new file mode 100644 index 00000000000..78a7afe6623 --- /dev/null +++ b/apps/sim/tools/instagram/index.ts @@ -0,0 +1,24 @@ +export { instagramDeleteCommentTool } from '@/tools/instagram/delete_comment' +export { instagramGetAccountInsightsTool } from '@/tools/instagram/get_account_insights' +export { instagramGetContainerStatusTool } from '@/tools/instagram/get_container_status' +export { instagramGetConversationMessagesTool } from '@/tools/instagram/get_conversation_messages' +export { instagramGetMediaTool } from '@/tools/instagram/get_media' +export { instagramGetMediaInsightsTool } from '@/tools/instagram/get_media_insights' +export { instagramGetMessageTool } from '@/tools/instagram/get_message' +export { instagramGetProfileTool } from '@/tools/instagram/get_profile' +export { instagramGetPublishingLimitTool } from '@/tools/instagram/get_publishing_limit' +export { instagramHideCommentTool } from '@/tools/instagram/hide_comment' +export { instagramListCommentsTool } from '@/tools/instagram/list_comments' +export { instagramListConversationsTool } from '@/tools/instagram/list_conversations' +export { instagramListMediaTool } from '@/tools/instagram/list_media' +export { instagramListStoriesTool } from '@/tools/instagram/list_stories' +export { instagramPrivateReplyTool } from '@/tools/instagram/private_reply' +export { instagramPublishCarouselTool } from '@/tools/instagram/publish_carousel' +export { instagramPublishImageTool } from '@/tools/instagram/publish_image' +export { instagramPublishReelTool } from '@/tools/instagram/publish_reel' +export { instagramPublishStoryTool } from '@/tools/instagram/publish_story' +export { instagramPublishVideoTool } from '@/tools/instagram/publish_video' +export { instagramReplyToCommentTool } from '@/tools/instagram/reply_to_comment' +export { instagramSendTextMessageTool } from '@/tools/instagram/send_text_message' +export { instagramSetCommentsEnabledTool } from '@/tools/instagram/set_comments_enabled' +export * from '@/tools/instagram/types' diff --git a/apps/sim/tools/instagram/list_comments.ts b/apps/sim/tools/instagram/list_comments.ts new file mode 100644 index 00000000000..e3f5f92b1a9 --- /dev/null +++ b/apps/sim/tools/instagram/list_comments.ts @@ -0,0 +1,96 @@ +import type { + InstagramListCommentsParams, + InstagramListCommentsResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramListCommentsTool: ToolConfig< + InstagramListCommentsParams, + InstagramListCommentsResponse +> = { + id: 'instagram_list_comments', + name: 'Instagram List Comments', + description: 'List comments on an Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of comments to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.mediaId.trim()}/comments`, { + fields: 'id,text,username,timestamp,like_count,hidden', + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { comments: [], nextCursor: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + comments: items.map((item: Record) => ({ + id: String(item.id ?? ''), + text: (item.text as string | undefined) ?? null, + username: (item.username as string | undefined) ?? null, + timestamp: (item.timestamp as string | undefined) ?? null, + likeCount: (item.like_count as number | undefined) ?? null, + hidden: (item.hidden as boolean | undefined) ?? null, + })), + // Graph includes cursors on every page; only `paging.next` signals another page. + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + comments: { + type: 'json', + description: 'Comments (id, text, username, timestamp, likeCount, hidden)', + }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/list_conversations.ts b/apps/sim/tools/instagram/list_conversations.ts new file mode 100644 index 00000000000..de5e78f4291 --- /dev/null +++ b/apps/sim/tools/instagram/list_conversations.ts @@ -0,0 +1,97 @@ +import type { + InstagramListConversationsParams, + InstagramListConversationsResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, clampGraphLimit, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramListConversationsTool: ToolConfig< + InstagramListConversationsParams, + InstagramListConversationsResponse +> = { + id: 'instagram_list_conversations', + name: 'Instagram List Conversations', + description: 'List Instagram Direct conversations for the professional account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of conversations to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() + ? `/${params.igUserId.trim()}/conversations` + : '/me/conversations' + return graphUrl(path, { + platform: 'instagram', + fields: 'id,updated_time', + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { conversations: [], nextCursor: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + const items = Array.isArray(data.data) ? data.data : [] + + return { + success: true, + output: { + conversations: items.map((item: Record) => ({ + id: String(item.id ?? ''), + updatedTime: (item.updated_time as string | undefined) ?? null, + })), + // Graph includes cursors on every page; only `paging.next` signals another page. + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + conversations: { + type: 'json', + description: 'Conversations (id, updatedTime)', + }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/list_media.ts b/apps/sim/tools/instagram/list_media.ts new file mode 100644 index 00000000000..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..842818d1996 --- /dev/null +++ b/apps/sim/tools/instagram/private_reply.ts @@ -0,0 +1,92 @@ +import type { + InstagramPrivateReplyParams, + InstagramPrivateReplyResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPrivateReplyTool: ToolConfig< + InstagramPrivateReplyParams, + InstagramPrivateReplyResponse +> = { + id: 'instagram_private_reply', + name: 'Instagram Private Reply', + description: 'Send a private Direct message reply to a commenter (one per commenter)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to privately reply to', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Private reply text', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { comment_id: params.commentId.trim() }, + message: { text: params.message }, + }), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: await readGraphError(response), + } + } + + const data = (await response.json()) as { + message_id?: string | number + recipient_id?: string | number + } + return { + success: true, + output: { + messageId: idString(data.message_id), + recipientId: idString(data.recipient_id), + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'Sent message id', optional: true }, + recipientId: { + type: 'string', + description: 'Instagram-scoped recipient id', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/publish_carousel.ts b/apps/sim/tools/instagram/publish_carousel.ts new file mode 100644 index 00000000000..1987d71a0d9 --- /dev/null +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -0,0 +1,83 @@ +import type { + InstagramPublishCarouselParams, + InstagramPublishResponse, +} from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishCarouselTool: ToolConfig< + InstagramPublishCarouselParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_carousel', + name: 'Instagram Publish Carousel', + description: 'Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + media: { + type: 'file[]', + required: true, + visibility: 'user-or-llm', + description: + '2-10 media files, or a comma-separated public URL string (prefix videos with video:)', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Carousel caption', + }, + }, + + request: { + url: '/api/tools/instagram/publish-carousel', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishCarouselParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + media: params.media, + caption: params.caption, + }), + }, + + transformResponse: 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 }, + } + }, + + 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..edf03061fbe --- /dev/null +++ b/apps/sim/tools/instagram/publish_image.ts @@ -0,0 +1,94 @@ +import type { InstagramPublishImageParams, InstagramPublishResponse } from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishImageTool: ToolConfig< + InstagramPublishImageParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_image', + name: 'Instagram Publish Image', + description: + 'Create and publish a single JPEG image post from an uploaded file or public HTTPS URL (polls until the container is ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + image: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'JPEG image file or public HTTPS URL (Meta will download it)', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post caption (max 2200 characters)', + }, + altText: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Accessibility alt text for the image', + }, + isAiGenerated: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Mark the post as AI-generated', + }, + }, + + request: { + url: '/api/tools/instagram/publish-image', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishImageParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + image: params.image, + caption: params.caption, + altText: params.altText, + isAiGenerated: params.isAiGenerated, + }), + }, + + transformResponse: 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 }, + } + }, + + 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..4791aec07b0 --- /dev/null +++ b/apps/sim/tools/instagram/publish_reel.ts @@ -0,0 +1,101 @@ +import type { InstagramPublishReelParams, InstagramPublishResponse } from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishReelTool: ToolConfig< + InstagramPublishReelParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_reel', + name: 'Instagram Publish Reel', + description: + 'Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + video: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'Reel video file or public HTTPS URL', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reel caption', + }, + cover: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + shareToFeed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Also share the Reel to the main feed', + }, + thumbOffset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Frame offset in milliseconds for the cover thumbnail', + }, + }, + + request: { + url: '/api/tools/instagram/publish-reel', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishReelParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + video: params.video, + cover: params.cover, + caption: params.caption, + shareToFeed: params.shareToFeed, + thumbOffset: params.thumbOffset, + }), + }, + + transformResponse: 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 }, + } + }, + + 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..efb6ab28926 --- /dev/null +++ b/apps/sim/tools/instagram/publish_story.ts @@ -0,0 +1,72 @@ +import type { InstagramPublishResponse, InstagramPublishStoryParams } from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishStoryTool: ToolConfig< + InstagramPublishStoryParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_story', + name: 'Instagram Publish Story', + description: 'Publish an image or video story from an uploaded file or public HTTPS URL', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + media: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'JPEG image or MP4/MOV video file, or a public HTTPS URL', + }, + }, + + request: { + url: '/api/tools/instagram/publish-story', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishStoryParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + media: params.media, + }), + }, + + transformResponse: 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 }, + } + }, + + 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..579f5c9665c --- /dev/null +++ b/apps/sim/tools/instagram/publish_video.ts @@ -0,0 +1,87 @@ +import type { InstagramPublishResponse, InstagramPublishVideoParams } from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishVideoTool: ToolConfig< + InstagramPublishVideoParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_video', + name: 'Instagram Publish Video', + description: + 'Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + video: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'Video file or public HTTPS URL', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post caption', + }, + cover: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + }, + + request: { + url: '/api/tools/instagram/publish-video', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishVideoParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + video: params.video, + cover: params.cover, + caption: params.caption, + }), + }, + + transformResponse: 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 }, + } + }, + + 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..5f58091f417 --- /dev/null +++ b/apps/sim/tools/instagram/reply_to_comment.ts @@ -0,0 +1,70 @@ +import type { + InstagramReplyToCommentParams, + InstagramReplyToCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramReplyToCommentTool: ToolConfig< + InstagramReplyToCommentParams, + InstagramReplyToCommentResponse +> = { + id: 'instagram_reply_to_comment', + name: 'Instagram Reply to Comment', + description: 'Reply to a comment on Instagram media', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to reply to', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Reply text', + }, + }, + + request: { + // Graph comment endpoints document message as a query/form parameter, not + // a JSON body (same style as hide_comment and set_comments_enabled). + url: (params) => graphUrl(`/${params.commentId.trim()}/replies`, { message: params.message }), + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { id: null }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { id: idString(data.id) }, + } + }, + + outputs: { + id: { type: 'string', description: 'Created reply comment id', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/send_text_message.ts b/apps/sim/tools/instagram/send_text_message.ts new file mode 100644 index 00000000000..974d33d476e --- /dev/null +++ b/apps/sim/tools/instagram/send_text_message.ts @@ -0,0 +1,89 @@ +import type { + InstagramSendTextMessageParams, + InstagramSendTextMessageResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramSendTextMessageTool: ToolConfig< + InstagramSendTextMessageParams, + InstagramSendTextMessageResponse +> = { + id: 'instagram_send_text_message', + name: 'Instagram Send Text Message', + description: + 'Send a text Direct message. The recipient must have messaged the account first (24h window).', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + recipientId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram-scoped user id (IGSID) of the recipient', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message text (max 1000 bytes UTF-8)', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { id: params.recipientId.trim() }, + message: { text: params.message }, + }), + }, + + transformResponse: async (response, params): Promise => { + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: params?.recipientId.trim() ?? null }, + error: await readGraphError(response), + } + } + + const data = (await response.json()) as { + message_id?: string | number + recipient_id?: string | number + } + return { + success: true, + output: { + messageId: idString(data.message_id), + recipientId: idString(data.recipient_id) ?? params?.recipientId.trim() ?? null, + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'Sent message id', optional: true }, + recipientId: { type: 'string', description: 'Recipient id', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/set_comments_enabled.ts b/apps/sim/tools/instagram/set_comments_enabled.ts new file mode 100644 index 00000000000..4cada4a260b --- /dev/null +++ b/apps/sim/tools/instagram/set_comments_enabled.ts @@ -0,0 +1,69 @@ +import type { + InstagramSetCommentsEnabledParams, + InstagramSetCommentsEnabledResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramSetCommentsEnabledTool: ToolConfig< + InstagramSetCommentsEnabledParams, + InstagramSetCommentsEnabledResponse +> = { + id: 'instagram_set_comments_enabled', + name: 'Instagram Set Comments Enabled', + description: 'Enable or disable comments on an Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + commentEnabled: { + type: 'boolean', + required: true, + visibility: 'user-or-llm', + description: 'True to enable comments, false to disable', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.mediaId.trim()}`, { comment_enabled: String(params.commentEnabled) }), + method: 'POST', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { success: false }, + error: await readGraphError(response), + } + } + + const data = await response.json() + return { + success: true, + output: { success: data.success === true || data.success === undefined }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the update succeeded' }, + }, +} diff --git a/apps/sim/tools/instagram/types.ts b/apps/sim/tools/instagram/types.ts new file mode 100644 index 00000000000..c2a3c8fcc6b --- /dev/null +++ b/apps/sim/tools/instagram/types.ts @@ -0,0 +1,349 @@ +import type { ToolResponse } from '@/tools/types' + +export interface InstagramAccessParams { + accessToken: string + igUserId?: string +} + +export interface InstagramGetProfileParams { + accessToken: string +} + +export interface InstagramGetProfileResponse extends ToolResponse { + output: { + userId: string | null + id: string | null + username: string | null + name: string | null + accountType: string | null + profilePictureUrl: string | null + followersCount: number | null + followsCount: number | null + mediaCount: number | null + } +} + +export interface InstagramListMediaParams extends InstagramAccessParams { + limit?: number + after?: string + 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 + }> + } +} + +/** Uploaded UserFile object, or a public HTTPS URL string (advanced paste / legacy). */ +export type InstagramMediaInput = object | string + +export interface InstagramPublishImageParams extends InstagramAccessParams { + image: InstagramMediaInput + caption?: string + altText?: string + isAiGenerated?: boolean +} + +export interface InstagramPublishVideoParams extends InstagramAccessParams { + video: InstagramMediaInput + caption?: string + cover?: InstagramMediaInput +} + +export interface InstagramPublishReelParams extends InstagramAccessParams { + video: InstagramMediaInput + caption?: string + cover?: InstagramMediaInput + shareToFeed?: boolean + thumbOffset?: number +} + +export interface InstagramPublishStoryParams extends InstagramAccessParams { + media: InstagramMediaInput +} + +export interface InstagramPublishCarouselParams extends InstagramAccessParams { + /** File array, single file, or comma-separated public URLs (prefix videos with video:). */ + media: InstagramMediaInput | InstagramMediaInput[] + caption?: string +} + +export interface InstagramPublishResponse extends ToolResponse { + output: { + containerId: string | null + mediaId: string | null + statusCode: string | null + } +} + +export interface InstagramGetContainerStatusParams { + accessToken: string + containerId: string +} + +export interface InstagramGetContainerStatusResponse extends ToolResponse { + output: { + containerId: string + statusCode: string | null + status: string | null + } +} + +export interface InstagramGetPublishingLimitParams extends InstagramAccessParams {} + +export interface InstagramGetPublishingLimitResponse extends ToolResponse { + output: { + quotaUsage: number | null + config: { + quotaTotal: number | null + quotaDuration: number | null + } | null + } +} + +export interface InstagramListCommentsParams { + accessToken: string + mediaId: string + limit?: number + after?: string +} + +export interface InstagramListCommentsResponse extends ToolResponse { + output: { + comments: Array<{ + id: string + text: string | null + username: string | null + timestamp: string | null + likeCount: number | null + hidden: boolean | null + }> + nextCursor: string | null + } +} + +export interface InstagramReplyToCommentParams { + accessToken: string + commentId: string + message: string +} + +export interface InstagramReplyToCommentResponse extends ToolResponse { + output: { + id: string | null + } +} + +export interface InstagramHideCommentParams { + accessToken: string + commentId: string + hide: boolean +} + +export interface InstagramHideCommentResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramDeleteCommentParams { + accessToken: string + commentId: string +} + +export interface InstagramDeleteCommentResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramSetCommentsEnabledParams { + accessToken: string + mediaId: string + commentEnabled: boolean +} + +export interface InstagramSetCommentsEnabledResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramPrivateReplyParams extends InstagramAccessParams { + commentId: string + message: string +} + +export interface InstagramPrivateReplyResponse extends ToolResponse { + output: { + messageId: string | null + recipientId: string | null + } +} + +export interface InstagramListConversationsParams extends InstagramAccessParams { + limit?: number + after?: string +} + +export interface InstagramListConversationsResponse extends ToolResponse { + output: { + conversations: Array<{ + id: string + updatedTime: string | null + }> + nextCursor: string | null + } +} + +export interface InstagramGetConversationMessagesParams { + accessToken: string + conversationId: string +} + +export interface InstagramGetConversationMessagesResponse extends ToolResponse { + output: { + conversationId: string + messages: Array<{ + id: string + createdTime: string | null + }> + } +} + +export interface InstagramGetMessageParams { + accessToken: string + messageId: string +} + +export interface InstagramGetMessageResponse extends ToolResponse { + output: { + id: string | null + createdTime: string | null + fromId: string | null + fromUsername: string | null + toId: string | null + message: string | null + } +} + +export interface InstagramSendTextMessageParams extends InstagramAccessParams { + recipientId: string + message: string +} + +export interface InstagramSendTextMessageResponse extends ToolResponse { + output: { + messageId: string | null + recipientId: string | null + } +} + +export interface InstagramGetAccountInsightsParams extends InstagramAccessParams { + metrics: string + period: string + since?: string + until?: string + metricType?: string + breakdown?: string + timeframe?: string +} + +export interface InstagramGetAccountInsightsResponse extends ToolResponse { + output: { + insights: Array<{ + name: string | null + period: string | null + title: string | null + description: string | null + values: unknown[] + totalValue: unknown + }> + } +} + +export interface InstagramGetMediaInsightsParams { + accessToken: string + mediaId: string + metrics: string +} + +export interface InstagramGetMediaInsightsResponse extends ToolResponse { + output: { + insights: Array<{ + name: string | null + period: string | null + title: string | null + description: string | null + values: unknown[] + totalValue: unknown + }> + } +} + +export type InstagramResponse = + | InstagramGetProfileResponse + | InstagramListMediaResponse + | InstagramGetMediaResponse + | InstagramListStoriesResponse + | InstagramPublishResponse + | InstagramGetContainerStatusResponse + | InstagramGetPublishingLimitResponse + | InstagramListCommentsResponse + | InstagramReplyToCommentResponse + | InstagramHideCommentResponse + | InstagramDeleteCommentResponse + | InstagramSetCommentsEnabledResponse + | InstagramPrivateReplyResponse + | InstagramListConversationsResponse + | InstagramGetConversationMessagesResponse + | InstagramGetMessageResponse + | InstagramSendTextMessageResponse + | InstagramGetAccountInsightsResponse + | InstagramGetMediaInsightsResponse diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts new file mode 100644 index 00000000000..ad2a96b22bb --- /dev/null +++ b/apps/sim/tools/instagram/utils.ts @@ -0,0 +1,202 @@ +import { sleep } from '@sim/utils/helpers' +import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram' + +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() +} + +/** + * Graph may serialize IDs as strings or numbers. Normalize to a string (or + * null) so downstream tools can safely call .trim() on wired ID outputs. + */ +export function idString(value: unknown): string | null { + if (value == null || value === '') return null + return String(value) +} + +export async function readGraphError(response: Response): Promise { + const text = await response.text() + try { + const json = JSON.parse(text) as { error?: { message?: string; code?: number } } + if (json.error?.message) { + return json.error.message + } + } catch { + // keep raw text + } + return text || response.statusText +} + +export async function resolveIgUserId(accessToken: string, igUserId?: string): Promise { + if (igUserId && igUserId.trim().length > 0) { + return igUserId.trim() + } + + const response = await fetch(graphUrl('/me', { fields: 'user_id' }), { + headers: bearerHeaders(accessToken), + }) + + if (!response.ok) { + throw new Error(`Failed to resolve Instagram user id: ${await readGraphError(response)}`) + } + + // Only /me's user_id is the Instagram professional account ID that publish + // and messaging paths expect; /me's id is an app-scoped ID from a different + // ID space, so never fall back to it. + const data = (await response.json()) as { user_id?: string | number } + if (data.user_id == null || data.user_id === '') { + throw new Error('Instagram /me response did not include a user_id') + } + return String(data.user_id) +} + +export type ContainerStatusCode = 'EXPIRED' | 'ERROR' | 'FINISHED' | 'IN_PROGRESS' | 'PUBLISHED' + +export async function getContainerStatus( + accessToken: string, + containerId: string +): Promise<{ statusCode: ContainerStatusCode | null; status: string | null }> { + const response = await fetch(graphUrl(`/${containerId}`, { fields: 'status_code,status' }), { + headers: bearerHeaders(accessToken), + }) + + if (!response.ok) { + throw new Error(`Failed to get container status: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { status_code?: string; status?: string } + return { + statusCode: (data.status_code as ContainerStatusCode | undefined) ?? null, + status: data.status ?? null, + } +} + +// Meta recommends checking once per minute for no more than five minutes. +// Six attempts = an immediate check plus five minute-spaced checks, so the +// full five-minute window is covered before timing out. +const POLL_INTERVAL_MS = 60_000 +const POLL_MAX_ATTEMPTS = 6 + +export async function waitForContainerReady( + accessToken: string, + containerId: string +): Promise<{ statusCode: ContainerStatusCode; status: string | null }> { + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + const { statusCode, status } = await getContainerStatus(accessToken, containerId) + + if (statusCode === 'FINISHED' || statusCode === 'PUBLISHED') { + return { statusCode, status } + } + if (statusCode === 'ERROR' || statusCode === 'EXPIRED') { + throw new Error( + `Instagram media container ${containerId} failed with status ${statusCode}${status ? `: ${status}` : ''}` + ) + } + + if (attempt < POLL_MAX_ATTEMPTS - 1) { + await sleep(POLL_INTERVAL_MS) + } + } + + throw new Error(`Timed out waiting for Instagram container ${containerId} to finish processing`) +} + +/** + * Graph content publishing endpoints document query/form parameters, not JSON + * bodies (JSON is only documented for the messaging endpoints), so publish + * POSTs are sent form-encoded. + */ +async function postGraphForm( + accessToken: string, + path: string, + params: Record +): Promise { + const form = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + form.set(key, String(value)) + } + } + + return fetch(graphUrl(path), { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), + }) +} + +export async function createMediaContainer( + accessToken: string, + igUserId: string, + body: Record +): Promise { + const response = await postGraphForm(accessToken, `/${igUserId}/media`, body) + + if (!response.ok) { + throw new Error(`Failed to create media container: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { id?: string | number } + const id = idString(data.id) + if (!id) { + throw new Error('Create media container response missing id') + } + return id +} + +export async function publishMediaContainer( + accessToken: string, + igUserId: string, + creationId: string +): Promise { + const response = await postGraphForm(accessToken, `/${igUserId}/media_publish`, { + creation_id: creationId, + }) + + if (!response.ok) { + throw new Error(`Failed to publish media: ${await readGraphError(response)}`) + } + + const data = (await response.json()) as { id?: string | number } + const id = idString(data.id) + if (!id) { + throw new Error('Publish media response missing id') + } + return id +} + +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..38d7e28ae09 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, @@ -4817,6 +4842,29 @@ export const tools: Record = { hex_update_collection: hexUpdateCollectionTool, hex_update_group: hexUpdateGroupTool, hex_update_project: hexUpdateProjectTool, + instagram_delete_comment: instagramDeleteCommentTool, + instagram_get_account_insights: instagramGetAccountInsightsTool, + instagram_get_container_status: instagramGetContainerStatusTool, + instagram_get_conversation_messages: instagramGetConversationMessagesTool, + instagram_get_media: instagramGetMediaTool, + instagram_get_media_insights: instagramGetMediaInsightsTool, + instagram_get_message: instagramGetMessageTool, + instagram_get_profile: instagramGetProfileTool, + instagram_get_publishing_limit: instagramGetPublishingLimitTool, + instagram_hide_comment: instagramHideCommentTool, + instagram_list_comments: instagramListCommentsTool, + instagram_list_conversations: instagramListConversationsTool, + instagram_list_media: instagramListMediaTool, + instagram_list_stories: instagramListStoriesTool, + instagram_private_reply: instagramPrivateReplyTool, + instagram_publish_carousel: instagramPublishCarouselTool, + instagram_publish_image: instagramPublishImageTool, + instagram_publish_reel: instagramPublishReelTool, + instagram_publish_story: instagramPublishStoryTool, + instagram_publish_video: instagramPublishVideoTool, + instagram_reply_to_comment: instagramReplyToCommentTool, + instagram_send_text_message: instagramSendTextMessageTool, + instagram_set_comments_enabled: instagramSetCommentsEnabledTool, instantly_activate_campaign: instantlyActivateCampaignTool, instantly_create_campaign: instantlyCreateCampaignTool, instantly_create_lead: instantlyCreateLeadTool,