diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 715b99e8656..d18862c51e0 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1467,6 +1467,21 @@ export function LinkedInIcon(props: SVGProps) { ) } +/** Instagram camera glyph rendered with the block's brand gradient background. */ +export function InstagramIcon(props: SVGProps) { + return ( + + ) +} + export function CrunchbaseIcon(props: SVGProps) { return ( Instagram > API setup with Instagram login) +# INSTAGRAM_CLIENT_ID= +# INSTAGRAM_CLIENT_SECRET= +# Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL). +# Gmail attachments do not need this. + +# TikTok OAuth (Optional - Client Key/Secret from the TikTok for Developers app) +# TIKTOK_CLIENT_ID= +# TIKTOK_CLIENT_SECRET= + # Azure Blob Storage takes precedence over S3 if both are configured # AZURE_ACCOUNT_NAME= # Azure storage account name # AZURE_ACCOUNT_KEY= # Azure storage account key diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts new file mode 100644 index 00000000000..85b4437748a --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -0,0 +1,89 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { type NextRequest, NextResponse } from 'next/server' +import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('InstagramAuthorize') + +export const dynamic = 'force-dynamic' + +const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' +const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' +const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 + +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(authorizeInstagramContract, request, {}) + if (!parsed.success) return parsed.response + const { returnUrl, workspaceId } = parsed.data.query + + if (workspaceId) { + const access = await checkWorkspaceAccess(workspaceId, session.user.id) + if (!access.canWrite) { + return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + } + await createConnectDraft({ + userId: session.user.id, + workspaceId, + providerId: 'instagram', + }) + } + + const clientId = env.INSTAGRAM_CLIENT_ID + if (!clientId) { + logger.error('INSTAGRAM_CLIENT_ID not configured') + return NextResponse.json({ error: 'Instagram client ID not configured' }, { status: 500 }) + } + + const baseUrl = getBaseUrl() + const state = generateShortId(32) + const redirectUri = `${baseUrl}/api/auth/oauth2/callback/instagram` + const scope = getCanonicalScopesForProvider('instagram').join(',') + + const authUrl = new URL('https://www.instagram.com/oauth/authorize') + authUrl.searchParams.set('client_id', clientId) + authUrl.searchParams.set('redirect_uri', redirectUri) + authUrl.searchParams.set('response_type', 'code') + authUrl.searchParams.set('scope', scope) + authUrl.searchParams.set('state', state) + + const response = NextResponse.redirect(authUrl.toString()) + response.cookies.set(INSTAGRAM_STATE_COOKIE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + + if (returnUrl && isSameOrigin(returnUrl)) { + response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } + + return response + } catch (error) { + logger.error('Error starting Instagram OAuth', { error }) + return NextResponse.json({ error: 'Failed to start Instagram OAuth' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts index 367821cb7d0..a5392ff2d57 100644 --- a/apps/sim/app/api/auth/oauth/utils.ts +++ b/apps/sim/app/api/auth/oauth/utils.ts @@ -8,6 +8,7 @@ import { withLeaderLock } from '@/lib/concurrency/leader-lock' import { coalesceLocally } from '@/lib/concurrency/singleflight' import { decryptSecret } from '@/lib/core/security/encryption' import { refreshOAuthToken } from '@/lib/oauth' +import { isInstagramProvider, shouldProactivelyRefreshInstagramToken } from '@/lib/oauth/instagram' import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider, @@ -546,6 +547,7 @@ export async function getOAuthToken(userId: string, providerId: string): Promise accessTokenExpiresAt: account.accessTokenExpiresAt, idToken: account.idToken, scope: account.scope, + updatedAt: account.updatedAt, }) .from(account) .where(and(eq(account.userId, userId), eq(account.providerId, providerId))) @@ -559,19 +561,33 @@ export async function getOAuthToken(userId: string, providerId: string): Promise const credential = connections[0] - // Determine whether we should refresh: missing token OR expired token + // Determine whether we should refresh: missing/expired token, or Instagram + // long-lived token nearing expiry (Meta cannot refresh after expiry). const now = new Date() const tokenExpiry = credential.accessTokenExpiresAt - const shouldAttemptRefresh = + const accessTokenNeedsRefresh = !!credential.refreshToken && (!credential.accessToken || (tokenExpiry && tokenExpiry < now)) + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: credential.accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) - if (shouldAttemptRefresh) { - return performCoalescedRefresh({ + if (accessTokenNeedsRefresh || instagramNeedsProactiveRefresh) { + const fresh = await performCoalescedRefresh({ accountId: credential.id, providerId, refreshToken: credential.refreshToken!, userId, }) + if (fresh) return fresh + if (!accessTokenNeedsRefresh && credential.accessToken) { + return credential.accessToken + } + return null } if (!credential.accessToken) { @@ -645,7 +661,18 @@ export async function refreshAccessTokenIfNeeded( refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold - const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh + // Instagram long-lived tokens can only be refreshed while still valid. + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(credential.providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) + + const shouldRefresh = + accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh const accessToken = credential.accessToken @@ -662,8 +689,8 @@ export async function refreshAccessTokenIfNeeded( }) if (fresh) return fresh - // If refresh was only triggered proactively (Microsoft refresh-token aging), - // the still-valid access token is a fine fallback. + // If refresh was only triggered proactively (Microsoft refresh-token aging / + // Instagram long-lived nearing expiry), the still-valid access token is fine. if (!accessTokenNeedsRefresh && accessToken) { logger.info(`[${requestId}] Refresh unavailable; reusing still-valid access token`) return accessToken @@ -711,7 +738,18 @@ export async function refreshTokenIfNeeded( refreshTokenExpiresAt && refreshTokenExpiresAt <= proactiveRefreshThreshold - const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh + // Instagram long-lived tokens can only be refreshed while still valid. + const instagramNeedsProactiveRefresh = + !!credential.refreshToken && + isInstagramProvider(credential.providerId) && + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt, + updatedAt: credential.updatedAt, + now, + }) + + const shouldRefresh = + accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh || instagramNeedsProactiveRefresh // If token appears valid and present, return it directly if (!shouldRefresh) { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 65bb6a773b9..2a9799d7863 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -1,81 +1,17 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getAllOAuthServices } from '@/lib/oauth/utils' +import { createConnectDraft } from '@/lib/credentials/connect-draft' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('OAuth2Authorize') export const dynamic = 'force-dynamic' -const DRAFT_TTL_MS = 15 * 60 * 1000 - -/** - * Creates the pending credential draft at click time so its TTL starts when the - * user actually initiates the connect. Better Auth's `account.create.after` hook - * consumes this draft to materialize the real credential after the OAuth - * callback; starting the clock here guarantees the draft outlives the (≤5 min) - * OAuth round-trip rather than expiring mid-flow and silently producing no - * credential. - */ -async function createConnectDraft(params: { - userId: string - workspaceId: string - providerId: string -}): Promise { - const { userId, workspaceId, providerId } = params - - const service = getAllOAuthServices().find((s) => s.providerId === providerId) - const serviceName = service?.name ?? providerId - - let displayName = serviceName - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - if (row?.name) { - displayName = `${row.name}'s ${serviceName}` - } - } catch { - // Fall back to service name only - } - - const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - expiresAt, - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { displayName, expiresAt, createdAt: now }, - }) - - logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId }) -} - /** * Browser-initiated entrypoint for linking a generic OAuth2 account. */ diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts new file mode 100644 index 00000000000..d1a5cdd0b46 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -0,0 +1,325 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramCallbackContract } from '@/lib/api/contracts/oauth-connections' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants' +import { + parseInstagramLongLivedToken, + parseInstagramProfile, + parseInstagramShortLivedToken, +} from '@/lib/oauth/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' + +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`) + ) + } + + 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(), + signal: request.signal, + }) + + if (!shortLivedResponse.ok) { + const errorText = await readResponseTextWithLimit(shortLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth token error response', + signal: request.signal, + }).catch(() => shortLivedResponse.statusText) + logger.error('Failed to exchange Instagram authorization code', { + status: shortLivedResponse.status, + error: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`) + ) + } + + const shortLivedBody = await readResponseJsonWithLimit(shortLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth token response', + signal: request.signal, + }) + const shortLived = parseInstagramShortLivedToken(shortLivedBody) + if (!shortLived) { + logger.error('Instagram short-lived token response was invalid') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`) + ) + } + + 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', + signal: request.signal, + }) + if (!longLivedResponse.ok) { + const errorText = await readResponseTextWithLimit(longLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth exchange error response', + signal: request.signal, + }).catch(() => longLivedResponse.statusText) + logger.error('Failed to exchange Instagram short-lived token', { + status: longLivedResponse.status, + error: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`) + ) + } + + const longLivedBody = await readResponseJsonWithLimit(longLivedResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth exchange response', + signal: request.signal, + }) + const longLived = parseInstagramLongLivedToken(longLivedBody) + + if (!longLived) { + logger.error('Instagram long-lived token response was invalid') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`) + ) + } + + const longLivedToken = longLived.access_token + const expiresIn = longLived.expires_in + + const profileUrl = new URL(`${INSTAGRAM_GRAPH_BASE}/me`) + profileUrl.searchParams.set('fields', 'user_id,username,name,account_type,profile_picture_url') + const profileResponse = await fetch(profileUrl, { + headers: { Authorization: `Bearer ${longLivedToken}` }, + signal: request.signal, + }) + + if (!profileResponse.ok) { + const errorText = await readResponseTextWithLimit(profileResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth profile error response', + signal: request.signal, + }).catch(() => profileResponse.statusText) + logger.error('Failed to fetch Instagram profile after OAuth', { + status: profileResponse.status, + error: errorText, + }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + ) + } + + const profileBody = await readResponseJsonWithLimit(profileResponse, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram OAuth profile response', + signal: request.signal, + }) + const profile = parseInstagramProfile(profileBody) + if (!profile) { + logger.error('Instagram profile response was invalid') + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + ) + } + + const igUserId = + profile.user_id != null && profile.user_id !== '' ? String(profile.user_id) : null + + 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') + 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: generateId(), + userId: session.user.id, + providerId: 'instagram', + accountId: igUserId, + accessToken: longLivedToken, + refreshToken: longLivedToken, + accessTokenExpiresAt, + scope, + createdAt: now, + updatedAt: now, + }, + { provider: 'Instagram', identifier: profile.username || igUserId } + ) + logger.info('Created Instagram account', { igUserId, username: profile.username }) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, session.user.id), + eq(account.providerId, 'instagram'), + eq(account.accountId, igUserId) + ), + })) + + if (persisted) { + try { + await processCredentialDraft({ + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) + } catch (draftError) { + logger.error('Failed to process credential draft for Instagram', { error: draftError }) + } + } + + const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value + const redirectUrl = + returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('instagram_connected', 'true') + + return clearOAuthCookies(NextResponse.redirect(finalUrl.toString())) + } catch (error) { + logger.error('Error in Instagram OAuth callback', { error }) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=instagram_callback_error`) + ) + } +}) diff --git a/apps/sim/app/api/files/storage-status/route.ts b/apps/sim/app/api/files/storage-status/route.ts new file mode 100644 index 00000000000..58dcadbdeb4 --- /dev/null +++ b/apps/sim/app/api/files/storage-status/route.ts @@ -0,0 +1,24 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' + +export const dynamic = 'force-dynamic' + +/** + * GET /api/files/storage-status + * Whether S3 or Azure Blob is configured (needed for Instagram file-upload publish). + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(fileStorageStatusContract, request, {}) + if (!parsed.success) return parsed.response + + return NextResponse.json({ cloudConfigured: hasCloudStorage() }) +}) diff --git a/apps/sim/app/api/tools/instagram/download-media/route.test.ts b/apps/sim/app/api/tools/instagram/download-media/route.test.ts new file mode 100644 index 00000000000..4d5ba6c32b2 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/download-media/route.test.ts @@ -0,0 +1,359 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockDeleteFileMetadata, + mockDeleteFiles, + mockDownloadFileFromUrl, + mockUploadExecutionFile, + mockUploadCopilotFile, +} = vi.hoisted(() => ({ + mockDeleteFileMetadata: vi.fn(), + mockDeleteFiles: vi.fn(), + mockDownloadFileFromUrl: vi.fn(), + mockUploadExecutionFile: vi.fn(), + mockUploadCopilotFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromUrl: mockDownloadFileFromUrl, +})) +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mockUploadExecutionFile, +})) +vi.mock('@/lib/uploads/contexts/copilot', () => ({ + uploadCopilotFile: mockUploadCopilotFile, +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + deleteFiles: mockDeleteFiles, +})) +vi.mock('@/lib/uploads/server/metadata', () => ({ + deleteFileMetadata: mockDeleteFileMetadata, +})) + +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation' +import { POST } from '@/app/api/tools/instagram/download-media/route' +import { instagramDownloadMediaTool } from '@/tools/instagram/download_media' + +const mockFetch = vi.fn() +const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0x01]) + +function executionFile(name: string, type: string, size: number) { + return { + id: `file-${name}`, + name, + url: `/api/files/serve/execution/${name}`, + size, + type, + key: `execution/workflow-1/execution-1/${name}`, + context: 'execution', + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockDownloadFileFromUrl.mockResolvedValue(Buffer.from('instagram-media')) + mockDeleteFiles.mockResolvedValue({ deleted: 0, failed: [] }) + mockDeleteFileMetadata.mockResolvedValue(true) + mockUploadExecutionFile.mockImplementation( + async ( + _context: { workspaceId: string; workflowId: string; executionId: string }, + buffer: Buffer, + name: string, + type: string + ) => executionFile(name, type, buffer.length) + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('POST /api/tools/instagram/download-media', () => { + it('stores a single download as an execution-scoped UserFile', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + id: 'media-1', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/media-1.jpg', + }) + ) + mockDownloadFileFromUrl.mockResolvedValueOnce(JPEG_BYTES) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'media-1', + filename: 'campaign-cover.png', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toEqual({ + success: true, + output: { + files: [executionFile('campaign-cover.jpg', 'image/jpeg', JPEG_BYTES.length)], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }, + }) + expect(mockDownloadFileFromUrl).toHaveBeenCalledWith( + 'https://scontent.example.com/media-1.jpg', + expect.objectContaining({ + maxBytes: MAX_FILE_SIZE, + signal: expect.any(AbortSignal), + userId: 'user-1', + }) + ) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + JPEG_BYTES, + 'campaign-cover.jpg', + 'image/jpeg', + 'user-1' + ) + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + }) + + it('downloads carousel children sequentially and preserves their order', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ + id: 'carousel-1', + media_type: 'CAROUSEL_ALBUM', + children: { data: [{ id: 'child-image' }, { id: 'child-video' }] }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + id: 'child-image', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/child-image.jpg', + }) + ) + .mockResolvedValueOnce( + Response.json({ + id: 'child-video', + media_type: 'VIDEO', + media_url: 'https://scontent.example.com/child-video.mp4', + }) + ) + mockDownloadFileFromUrl + .mockResolvedValueOnce(JPEG_BYTES) + .mockResolvedValueOnce(Buffer.from('video')) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'carousel-1', + filename: 'launch', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data.output).toEqual({ + files: [ + executionFile('launch-1.jpg', 'image/jpeg', JPEG_BYTES.length), + executionFile('launch-2.mp4', 'video/mp4', 5), + ], + mediaId: 'carousel-1', + mediaType: 'CAROUSEL_ALBUM', + downloadedCount: 2, + }) + expect(mockDownloadFileFromUrl.mock.calls.map(([url]) => url)).toEqual([ + 'https://scontent.example.com/child-image.jpg', + 'https://scontent.example.com/child-video.mp4', + ]) + expect(mockUploadExecutionFile.mock.calls.map(([, , name]) => name)).toEqual([ + 'launch-1.jpg', + 'launch-2.mp4', + ]) + expect(mockUploadExecutionFile.mock.invocationCallOrder[0]).toBeLessThan( + mockFetch.mock.invocationCallOrder[2] + ) + }) + + it('rolls back earlier carousel files when a later child cannot be downloaded', async () => { + mockFetch + .mockResolvedValueOnce( + Response.json({ + id: 'carousel-1', + media_type: 'CAROUSEL_ALBUM', + children: { data: [{ id: 'child-image' }, { id: 'child-missing' }] }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + id: 'child-image', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/child-image.jpg', + }) + ) + .mockResolvedValueOnce( + Response.json( + { error: { message: 'The second carousel item is unavailable' } }, + { status: 404 } + ) + ) + mockDownloadFileFromUrl.mockResolvedValueOnce(JPEG_BYTES) + mockDeleteFiles.mockResolvedValueOnce({ deleted: 1, failed: [] }) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'carousel-1', + filename: 'launch', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + const storedFile = executionFile('launch-1.jpg', 'image/jpeg', JPEG_BYTES.length) + expect(response.status).toBe(404) + expect(mockDeleteFiles).toHaveBeenCalledWith([storedFile.key], 'execution') + expect(mockDeleteFileMetadata).toHaveBeenCalledWith(storedFile.key) + }) + + it('does not preserve an image MIME type when the downloaded bytes are not a raster image', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + id: 'media-invalid-image', + media_type: 'IMAGE', + media_url: 'https://scontent.example.com/media-invalid-image.jpg', + }) + ) + const invalidImage = Buffer.from('not an image') + mockDownloadFileFromUrl.mockResolvedValueOnce(invalidImage) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'media-invalid-image', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + ) + + expect(response.status).toBe(200) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + expect.any(Object), + invalidImage, + 'instagram-media-invalid-image.bin', + 'application/octet-stream', + 'user-1' + ) + }) + + it('returns 413 when a media download exceeds the size cap', async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ + id: 'media-large', + media_type: 'VIDEO', + media_url: 'https://scontent.example.com/media-large.mp4', + }) + ) + mockDownloadFileFromUrl.mockRejectedValueOnce( + new PayloadSizeLimitError({ + label: 'Instagram media download', + maxBytes: MAX_FILE_SIZE, + observedBytes: MAX_FILE_SIZE + 1, + }) + ) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'instagram-token', + mediaId: 'media-large', + }) + ) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + success: false, + error: 'Instagram media exceeds the 100 MB canonical User File limit', + }) + expect(mockUploadExecutionFile).not.toHaveBeenCalled() + expect(mockUploadCopilotFile).not.toHaveBeenCalled() + }) +}) + +describe('instagramDownloadMediaTool', () => { + it('documents the canonical User File size limit', () => { + expect(instagramDownloadMediaTool.description).toContain('100 MB max per file') + expect(instagramDownloadMediaTool.outputs?.files.description).toContain('100 MB max each') + }) + + it('forwards execution context and returns canonical file-array output', async () => { + const body = instagramDownloadMediaTool.request.body?.({ + accessToken: 'instagram-token', + mediaId: 'media-1', + filename: 'campaign-cover', + _context: { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(body).toEqual({ + accessToken: 'instagram-token', + mediaId: 'media-1', + filename: 'campaign-cover', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + + const file = executionFile('campaign-cover.jpg', 'image/jpeg', 11) + const result = await instagramDownloadMediaTool.transformResponse?.( + Response.json({ + success: true, + output: { + files: [file], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }, + }), + { + accessToken: 'instagram-token', + mediaId: 'media-1', + } + ) + + expect(result).toEqual({ + success: true, + output: { + files: [file], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }, + }) + expect(instagramDownloadMediaTool.outputs?.files.type).toBe('file[]') + }) +}) diff --git a/apps/sim/app/api/tools/instagram/download-media/route.ts b/apps/sim/app/api/tools/instagram/download-media/route.ts new file mode 100644 index 00000000000..c4611a4d488 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/download-media/route.ts @@ -0,0 +1,380 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { type NextRequest, NextResponse } from 'next/server' +import { + type InstagramDownloadMediaRouteResponse, + instagramDownloadMediaContract, + instagramDownloadMediaOutputSchema, +} from '@/lib/api/contracts/tools/instagram' +import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { isPayloadSizeLimitError, readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot' +import { uploadExecutionFile } from '@/lib/uploads/contexts/execution' +import { deleteFiles } from '@/lib/uploads/core/storage-service' +import { deleteFileMetadata } from '@/lib/uploads/server/metadata' +import type { StorageContext } from '@/lib/uploads/shared/types' +import { + getExtensionFromMimeType, + getFileExtension, + getMimeTypeFromExtension, +} from '@/lib/uploads/utils/file-utils' +import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' +import { MAX_FILE_SIZE, sniffImageContentType } from '@/lib/uploads/utils/validation' +import { sanitizeFileName } from '@/executor/constants' +import type { UserFile } from '@/executor/types' +import { bearerHeaders, graphUrl, idString, readGraphError } from '@/tools/instagram/utils' + +const logger = createLogger('InstagramDownloadMediaAPI') +const MAX_GRAPH_METADATA_BYTES = 256 * 1024 +const MAX_CAROUSEL_ITEMS = 10 +const ROOT_MEDIA_FIELDS = 'id,media_type,media_url,children{id}' +const CHILD_MEDIA_FIELDS = 'id,media_type,media_url' + +export const dynamic = 'force-dynamic' +export const maxDuration = 900 + +interface InstagramMediaMetadata { + id: string + mediaType: string | null + mediaUrl: string | null + childIds: string[] +} + +type InstagramMediaMetadataResult = + | { success: true; data: InstagramMediaMetadata } + | { success: false; error: string; status: number } + +function failureResponse(error: string, status: number) { + const body = { success: false, error } satisfies InstagramDownloadMediaRouteResponse + return NextResponse.json(body, { status }) +} + +function normalizedId(value: unknown): string | null { + return typeof value === 'string' || typeof value === 'number' ? idString(value) : null +} + +function parseMediaMetadata(data: unknown): InstagramMediaMetadataResult { + if (!isRecordLike(data)) { + return { success: false, error: 'Instagram returned invalid media metadata', status: 502 } + } + + const id = normalizedId(data.id) + if (!id) { + return { success: false, error: 'Instagram media metadata did not include an ID', status: 502 } + } + + const mediaType = typeof data.media_type === 'string' ? data.media_type : null + const mediaUrl = + typeof data.media_url === 'string' && data.media_url.length > 0 ? data.media_url : null + const children = data.children + + if (children === undefined) { + return { success: true, data: { id, mediaType, mediaUrl, childIds: [] } } + } + + if (!isRecordLike(children) || !Array.isArray(children.data)) { + return { success: false, error: 'Instagram returned invalid carousel metadata', status: 502 } + } + + if (children.data.length > MAX_CAROUSEL_ITEMS) { + return { + success: false, + error: `Instagram carousel exceeds the ${MAX_CAROUSEL_ITEMS}-item download limit`, + status: 502, + } + } + + const childIds: string[] = [] + for (const child of children.data) { + if (!isRecordLike(child)) { + return { success: false, error: 'Instagram returned an invalid carousel item', status: 502 } + } + const childId = normalizedId(child.id) + if (!childId) { + return { + success: false, + error: 'Instagram carousel item did not include an ID', + status: 502, + } + } + childIds.push(childId) + } + + return { success: true, data: { id, mediaType, mediaUrl, childIds } } +} + +async function fetchMediaMetadata({ + accessToken, + mediaId, + fields, + signal, +}: { + accessToken: string + mediaId: string + fields: string + signal: AbortSignal +}): Promise { + const response = await fetch(graphUrl(`/${encodeURIComponent(mediaId)}`, { fields }), { + headers: bearerHeaders(accessToken), + signal, + }) + + if (!response.ok) { + return { + success: false, + error: await readGraphError(response), + status: response.status >= 400 && response.status < 500 ? response.status : 502, + } + } + + const data = await readResponseJsonWithLimit(response, { + maxBytes: MAX_GRAPH_METADATA_BYTES, + label: `Instagram media ${mediaId} metadata`, + signal, + }) + return parseMediaMetadata(data) +} + +function inferContentType(mediaUrl: string, mediaType: string | null): string { + if (mediaType === 'VIDEO') return 'video/mp4' + if (mediaType === 'IMAGE') return 'image/jpeg' + + let extension = '' + try { + extension = getFileExtension(new URL(mediaUrl).pathname) + } catch { + extension = '' + } + + const mimeType = getMimeTypeFromExtension(extension) + if (mimeType !== 'application/octet-stream') return mimeType + return 'application/octet-stream' +} + +function resolveDownloadedContentType( + buffer: Buffer, + mediaUrl: string, + mediaType: string | null +): string { + const inferred = inferContentType(mediaUrl, mediaType) + if (mediaType === 'IMAGE' || inferred.startsWith('image/')) { + return sniffImageContentType(buffer) ?? 'application/octet-stream' + } + return inferred +} + +function buildFilename({ + filename, + mediaId, + contentType, + itemIndex, + itemCount, +}: { + filename?: string + mediaId: string + contentType: string + itemIndex: number + itemCount: number +}): string { + const extension = getExtensionFromMimeType(contentType) ?? 'bin' + if (!filename) return sanitizeFileName(`instagram-${mediaId}.${extension}`) + + const sanitized = sanitizeFileName(filename).replace(/^\.+/, '') + const lastDot = sanitized.lastIndexOf('.') + const base = (lastDot > 0 ? sanitized.slice(0, lastDot) : sanitized) || `instagram-${mediaId}` + const suffix = itemCount > 1 ? `-${itemIndex + 1}` : '' + return `${base}${suffix}.${extension}` +} + +async function downloadAndStoreMedia({ + metadata, + filename, + itemIndex, + itemCount, + userId, + executionContext, + signal, +}: { + metadata: InstagramMediaMetadata + filename?: string + itemIndex: number + itemCount: number + userId: string + executionContext?: { workspaceId: string; workflowId: string; executionId: string } + signal: AbortSignal +}): Promise { + if (!metadata.mediaUrl) { + throw new Error(`Instagram media ${metadata.id} did not include a downloadable URL`) + } + + const buffer = await downloadFileFromUrl(metadata.mediaUrl, { + maxBytes: MAX_FILE_SIZE, + signal, + userId, + }) + const contentType = resolveDownloadedContentType(buffer, metadata.mediaUrl, metadata.mediaType) + const storedFilename = buildFilename({ + filename, + mediaId: metadata.id, + contentType, + itemIndex, + itemCount, + }) + if (executionContext) { + return uploadExecutionFile(executionContext, buffer, storedFilename, contentType, userId) + } + + return uploadCopilotFile({ + buffer, + fileName: storedFilename, + contentType, + userId, + }) +} + +/** Removes successfully stored files when a multi-item download cannot return a complete result. */ +async function rollbackStoredFiles(files: UserFile[], context: StorageContext): Promise { + if (files.length === 0) return + + const keys = files.map((file) => file.key) + let failedKeys: Set + try { + const deletion = await deleteFiles(keys, context) + failedKeys = new Set(deletion.failed.map((failure) => failure.key)) + if (deletion.failed.length > 0) { + logger.warn('Instagram media rollback could not delete every stored object', { + context, + failedKeys: [...failedKeys], + }) + } + } catch (error) { + logger.warn('Instagram media rollback failed before metadata cleanup', { + context, + error: getErrorMessage(error), + keys, + }) + return + } + + for (const key of keys) { + if (failedKeys.has(key)) continue + try { + await deleteFileMetadata(key) + } catch (error) { + logger.warn('Instagram media rollback could not delete file metadata', { + error: getErrorMessage(error), + key, + }) + } + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return failureResponse(authResult.error || 'Unauthorized', 401) + } + + const parsed = await parseRequest( + instagramDownloadMediaContract, + request, + {}, + { + validationErrorResponse: (error) => + failureResponse(getValidationErrorMessage(error, 'Invalid request data'), 400), + } + ) + if (!parsed.success) return parsed.response + + const files: UserFile[] = [] + let storageContext: StorageContext = 'copilot' + try { + const body = parsed.data.body + const rootResult = await fetchMediaMetadata({ + accessToken: body.accessToken, + mediaId: body.mediaId, + fields: ROOT_MEDIA_FIELDS, + signal: request.signal, + }) + if (!rootResult.success) return failureResponse(rootResult.error, rootResult.status) + + const rootMedia = rootResult.data + const itemCount = rootMedia.childIds.length || 1 + const executionContext = + body.workspaceId && body.workflowId && body.executionId + ? { + workspaceId: body.workspaceId, + workflowId: body.workflowId, + executionId: body.executionId, + } + : undefined + storageContext = executionContext ? 'execution' : 'copilot' + + if (rootMedia.childIds.length === 0) { + files.push( + await downloadAndStoreMedia({ + metadata: rootMedia, + filename: body.filename, + itemIndex: 0, + itemCount, + userId: authResult.userId, + executionContext, + signal: request.signal, + }) + ) + } else { + for (const [itemIndex, childId] of rootMedia.childIds.entries()) { + const childResult = await fetchMediaMetadata({ + accessToken: body.accessToken, + mediaId: childId, + fields: CHILD_MEDIA_FIELDS, + signal: request.signal, + }) + if (!childResult.success) { + await rollbackStoredFiles(files, storageContext) + return failureResponse(childResult.error, childResult.status) + } + + files.push( + await downloadAndStoreMedia({ + metadata: childResult.data, + filename: body.filename, + itemIndex, + itemCount, + userId: authResult.userId, + executionContext, + signal: request.signal, + }) + ) + } + } + + const output = instagramDownloadMediaOutputSchema.parse({ + files, + mediaId: rootMedia.id, + mediaType: rootMedia.mediaType, + downloadedCount: files.length, + }) + const responseBody = { + success: true, + output, + } satisfies InstagramDownloadMediaRouteResponse + + return NextResponse.json(responseBody) + } catch (error) { + await rollbackStoredFiles(files, storageContext) + logger.error('Instagram media download failed', { error }) + + if (isPayloadSizeLimitError(error) && error.maxBytes === MAX_FILE_SIZE) { + return failureResponse('Instagram media exceeds the 100 MB canonical User File limit', 413) + } + + return failureResponse( + getErrorMessage(error, 'Failed to download Instagram media'), + isPayloadSizeLimitError(error) ? 413 : 500 + ) + } +}) diff --git a/apps/sim/app/api/tools/instagram/publish-carousel/route.ts b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts new file mode 100644 index 00000000000..a1f73b9d6af --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-carousel/route.ts @@ -0,0 +1,130 @@ +import { createLogger, getRequestContext } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishCarouselContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { 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 = getRequestContext()?.requestId ?? 'unknown' + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized Instagram publish carousel', { error: 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, + request.signal + ) + + 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, request.signal) + ) + } + + const childResults = await Promise.allSettled( + childIds.map((childId) => waitForContainerReady(body.accessToken, childId, request.signal)) + ) + 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, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error('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..8b97b5c9228 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-image/route.ts @@ -0,0 +1,103 @@ +import { createLogger, getRequestContext } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishImageContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { 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 = getRequestContext()?.requestId ?? 'unknown' + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized Instagram publish image', { error: 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, + request.signal + ) + 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, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error('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..b68a23da438 --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-reel/route.ts @@ -0,0 +1,131 @@ +import { createLogger, getRequestContext } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishReelContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { 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 = getRequestContext()?.requestId ?? 'unknown' + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized Instagram publish reel', { error: 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, + request.signal + ) + 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, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error('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..bee2b6ed62e --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-story/route.ts @@ -0,0 +1,105 @@ +import { createLogger, getRequestContext } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishStoryContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { 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 = getRequestContext()?.requestId ?? 'unknown' + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized Instagram publish story', { error: 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, + request.signal + ) + 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, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error('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..d2eb3fe5b3e --- /dev/null +++ b/apps/sim/app/api/tools/instagram/publish-video/route.ts @@ -0,0 +1,128 @@ +import { createLogger, getRequestContext } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { instagramPublishVideoContract } from '@/lib/api/contracts/tools/instagram' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { 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 = getRequestContext()?.requestId ?? 'unknown' + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn('Unauthorized Instagram publish video', { error: 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, + request.signal + ) + 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, + request.signal + ) + const { statusCode } = await waitForContainerReady( + body.accessToken, + containerId, + request.signal + ) + const mediaId = await publishMediaContainer( + body.accessToken, + igUserId, + containerId, + request.signal + ) + + return NextResponse.json({ + success: true, + output: { containerId, mediaId, statusCode }, + }) + } catch (error) { + logger.error('Instagram publish video failed', { error }) + return NextResponse.json( + { + success: false, + error: getErrorMessage(error, 'Failed to publish video'), + output: { containerId: null, mediaId: null, statusCode: null }, + }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/tiktok/publish-video/route.test.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.test.ts new file mode 100644 index 00000000000..4bd583a2a92 --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/route.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' + +const { + mockAssertToolFileAccess, + mockComputeTikTokChunkPlan, + mockGetStoredVideoSize, + mockStreamStoredVideoToTikTok, +} = vi.hoisted(() => ({ + mockAssertToolFileAccess: vi.fn(), + mockComputeTikTokChunkPlan: vi.fn(() => ({ + chunkSize: 10_000_000, + totalChunkCount: 2, + })), + mockGetStoredVideoSize: vi.fn(), + mockStreamStoredVideoToTikTok: vi.fn(), +})) + +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: mockAssertToolFileAccess, +})) + +vi.mock('@/app/api/tools/tiktok/publish-video/upload', () => ({ + computeTikTokChunkPlan: mockComputeTikTokChunkPlan, + getStoredVideoSize: mockGetStoredVideoSize, + streamStoredVideoToTikTok: mockStreamStoredVideoToTikTok, + TIKTOK_MAX_VIDEO_BYTES: 250 * 1024 * 1024, +})) + +import { POST } from '@/app/api/tools/tiktok/publish-video/route' + +const file = { + key: 'workspace/workspace-1/video.mp4', + name: 'video.mp4', + size: 1, + type: 'video/mp4', +} + +describe('POST /api/tools/tiktok/publish-video', () => { + beforeEach(() => { + vi.clearAllMocks() + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockAssertToolFileAccess.mockResolvedValue(null) + mockGetStoredVideoSize.mockResolvedValue(20_000_000) + mockComputeTikTokChunkPlan.mockReturnValue({ + chunkSize: 10_000_000, + totalChunkCount: 2, + }) + mockStreamStoredVideoToTikTok.mockResolvedValue(undefined) + }) + + it('uses authoritative storage size for initialization and streaming', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + data: { publish_id: 'publish-1', upload_url: 'https://upload.example/video' }, + error: { code: 'ok' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + const request = createMockRequest('POST', { + accessToken: 'access-token', + mode: 'draft', + file, + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + output: { publishId: 'publish-1' }, + }) + expect(mockGetStoredVideoSize).toHaveBeenCalledWith({ + key: file.key, + context: 'workspace', + signal: request.signal, + }) + const init = JSON.parse(fetchMock.mock.calls[0][1]?.body as string) as { + source_info: Record + } + expect(init.source_info).toEqual({ + source: 'FILE_UPLOAD', + video_size: 20_000_000, + chunk_size: 10_000_000, + total_chunk_count: 2, + }) + expect(fetchMock.mock.calls[0][1]?.signal).toBe(request.signal) + expect(mockStreamStoredVideoToTikTok).toHaveBeenCalledWith({ + key: file.key, + context: 'workspace', + uploadUrl: 'https://upload.example/video', + totalBytes: 20_000_000, + mimeType: 'video/mp4', + requestId: 'mock-request-id', + signal: request.signal, + }) + }) + + it('keeps Direct Post unavailable until per-post approval exists', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'access-token', + mode: 'direct', + file, + musicUsageConsent: 'accepted', + postInfo: { + privacy_level: 'SELF_ONLY', + disable_duet: true, + disable_stitch: true, + disable_comment: true, + brand_content_toggle: false, + }, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: expect.stringMatching(/Direct Post is not available/), + }) + expect(mockGetStoredVideoSize).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns 413 when the storage object exceeds the relay limit', async () => { + mockGetStoredVideoSize.mockRejectedValue( + new PayloadSizeLimitError({ + label: 'TikTok video upload', + maxBytes: 250 * 1024 * 1024, + observedBytes: 251 * 1024 * 1024, + }) + ) + + const response = await POST( + createMockRequest('POST', { + accessToken: 'access-token', + mode: 'draft', + file, + }) + ) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'Video exceeds the 250MB limit for file uploads.', + }) + }) +}) diff --git a/apps/sim/app/api/tools/tiktok/publish-video/route.ts b/apps/sim/app/api/tools/tiktok/publish-video/route.ts index d60157807df..ec1cf21c55b 100644 --- a/apps/sim/app/api/tools/tiktok/publish-video/route.ts +++ b/apps/sim/app/api/tools/tiktok/publish-video/route.ts @@ -5,129 +5,38 @@ import { tiktokPublishVideoContract } from '@/lib/api/contracts/tiktok-tools' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' -import { isPayloadSizeLimitError, readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getFileExtension, getMimeTypeFromExtension, processSingleFileToUserFile, + resolveTrustedFileContext, } from '@/lib/uploads/utils/file-utils' -import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + computeTikTokChunkPlan, + getStoredVideoSize, + streamStoredVideoToTikTok, + TIKTOK_MAX_VIDEO_BYTES, +} from '@/app/api/tools/tiktok/publish-video/upload' import type { UserFile } from '@/executor/types' import { tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas' import { readTikTokApiResponse } from '@/tools/tiktok/utils' export const dynamic = 'force-dynamic' +export const maxDuration = 900 const logger = createLogger('TikTokPublishVideoAPI') const TIKTOK_VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/quicktime', 'video/webm']) -/** TikTok requires each chunk between 5MB and 64MB; the final chunk absorbs the remainder (up to ~2x this size, well under the 128MB cap). Capped at 1000 chunks total, which this default comfortably satisfies up to TikTok's 4GB video size limit. */ -const DEFAULT_CHUNK_SIZE = 10_000_000 -const TIKTOK_ERROR_RESPONSE_MAX_BYTES = 64 * 1024 - -/** Maximum size this route will buffer in memory for a single file-upload request. TikTok's - * own limit is 4GB, but relaying that much through this server's memory per request isn't - * safe under concurrent load. Enforced before downloading the file so an oversized upload - * fails fast with a clean 413 instead of materializing hundreds of MB to multiple GB - * in-process. */ -const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024 - -function computeChunkPlan(totalBytes: number): { chunkSize: number; totalChunkCount: number } { - if (totalBytes <= DEFAULT_CHUNK_SIZE) { - return { chunkSize: totalBytes, totalChunkCount: 1 } - } - const totalChunkCount = Math.floor(totalBytes / DEFAULT_CHUNK_SIZE) - return { chunkSize: DEFAULT_CHUNK_SIZE, totalChunkCount } -} - function resolveVideoMimeType(fileName: string, fileType: string | undefined): string | null { if (fileType && TIKTOK_VIDEO_MIME_TYPES.has(fileType)) return fileType const fromExtension = getMimeTypeFromExtension(getFileExtension(fileName)) return TIKTOK_VIDEO_MIME_TYPES.has(fromExtension) ? fromExtension : null } -async function validateDirectPostSettings( - accessToken: string, - postInfo: { privacy_level: string; brand_content_toggle: boolean }, - requestId: string -): Promise { - if (postInfo.brand_content_toggle && postInfo.privacy_level === 'SELF_ONLY') { - return 'Branded content cannot use Only Me privacy.' - } - - const response = await fetch('https://open.tiktokapis.com/v2/post/publish/creator_info/query/', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: '{}', - }) - const data = await response.json() - if (!response.ok || (data.error?.code && data.error.code !== 'ok')) { - logger.warn(`[${requestId}] TikTok creator-info preflight failed`, { - status: response.status, - code: data.error?.code, - }) - return data.error?.message || 'TikTok creator information could not be verified.' - } - - const privacyOptions: unknown = data.data?.privacy_level_options - if ( - !Array.isArray(privacyOptions) || - !privacyOptions.some((option) => option === postInfo.privacy_level) - ) { - return `The selected privacy level (${postInfo.privacy_level}) is not currently available for this TikTok account. Run Query Creator Info and choose one of the returned options.` - } - - return null -} - -async function uploadChunks( - uploadUrl: string, - buffer: Buffer, - mimeType: string, - requestId: string -): Promise { - const totalBytes = buffer.length - const { chunkSize, totalChunkCount } = computeChunkPlan(totalBytes) - - for (let i = 0; i < totalChunkCount; i++) { - const start = i * chunkSize - const isLastChunk = i === totalChunkCount - 1 - const end = isLastChunk ? totalBytes - 1 : start + chunkSize - 1 - const chunk = buffer.subarray(start, end + 1) - - const response = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Type': mimeType, - 'Content-Length': String(chunk.length), - 'Content-Range': `bytes ${start}-${end}/${totalBytes}`, - }, - body: new Uint8Array(chunk), - }) - - if (!response.ok) { - const errorText = await readResponseTextWithLimit(response, { - maxBytes: TIKTOK_ERROR_RESPONSE_MAX_BYTES, - label: 'TikTok upload error response', - }).catch(() => 'Error response exceeded the allowed size') - logger.error(`[${requestId}] TikTok chunk upload failed`, { - chunkIndex: i, - status: response.status, - errorText, - }) - throw new Error( - `TikTok rejected video chunk ${i + 1}/${totalChunkCount}: ${response.status} ${errorText || response.statusText}` - ) - } - } -} - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -145,6 +54,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const data = parsed.data.body + if (data.mode === 'direct') { + return NextResponse.json( + { + success: false, + error: + 'TikTok Direct Post is not available until per-post human approval is implemented. Use Upload Video Draft instead.', + }, + { status: 400 } + ) + } + let userFile: UserFile try { userFile = processSingleFileToUserFile(data.file, requestId, logger) @@ -158,17 +78,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) if (denied) return denied - if (data.mode === 'direct') { - const settingsError = await validateDirectPostSettings( - data.accessToken, - data.postInfo, - requestId - ) - if (settingsError) { - return NextResponse.json({ success: false, error: settingsError }, { status: 400 }) - } - } - const mimeType = resolveVideoMimeType(userFile.name, userFile.type) if (!mimeType) { return NextResponse.json( @@ -180,57 +89,58 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - logger.info(`[${requestId}] Downloading video from storage`, { - fileName: userFile.name, - size: userFile.size, - }) - - const fileBuffer = await downloadFileFromStorage(userFile, requestId, logger, { - maxBytes: TIKTOK_MAX_VIDEO_BYTES, + const context = resolveTrustedFileContext(userFile.key, userFile.context) + const videoSize = await getStoredVideoSize({ + key: userFile.key, + context, + signal: request.signal, }) - if (fileBuffer.length === 0) { + if (videoSize === 0) { return NextResponse.json( { success: false, error: 'The video file is empty.' }, { status: 400 } ) } - const { chunkSize, totalChunkCount } = computeChunkPlan(fileBuffer.length) - const initUrl = - data.mode === 'draft' - ? 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/' - : 'https://open.tiktokapis.com/v2/post/publish/video/init/' + logger.info(`[${requestId}] Resolved video from storage`, { + fileName: userFile.name, + declaredSize: userFile.size, + storageSize: videoSize, + }) - const initBody: Record = { + const { chunkSize, totalChunkCount } = computeTikTokChunkPlan(videoSize) + const initBody = { source_info: { source: 'FILE_UPLOAD', - video_size: fileBuffer.length, + video_size: videoSize, chunk_size: chunkSize, total_chunk_count: totalChunkCount, }, } - if (data.mode === 'direct') { - initBody.post_info = data.postInfo - } - logger.info(`[${requestId}] Initializing TikTok video ${data.mode}`, { - videoSize: fileBuffer.length, + logger.info(`[${requestId}] Initializing TikTok video draft`, { + videoSize, chunkSize, totalChunkCount, }) - const initResponse = await fetch(initUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${data.accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: JSON.stringify(initBody), - }) + const initResponse = await fetch( + 'https://open.tiktokapis.com/v2/post/publish/inbox/video/init/', + { + method: 'POST', + headers: { + Authorization: `Bearer ${data.accessToken}`, + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: JSON.stringify(initBody), + signal: request.signal, + } + ) const { data: initData, error: initError } = await readTikTokApiResponse( initResponse, - tiktokPublishInitApiDataSchema + tiktokPublishInitApiDataSchema, + { signal: request.signal } ) if (initError) { @@ -252,8 +162,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } try { - await uploadChunks(uploadUrl, fileBuffer, mimeType, requestId) + await streamStoredVideoToTikTok({ + key: userFile.key, + context, + uploadUrl, + totalBytes: videoSize, + mimeType, + requestId, + signal: request.signal, + }) } catch (error) { + if (request.signal.aborted) throw error return NextResponse.json( { success: false, error: getErrorMessage(error, 'Failed to upload video to TikTok') }, { status: 502 } @@ -278,6 +197,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 413 } ) } + if (request.signal.aborted) { + return NextResponse.json( + { success: false, error: 'TikTok video upload was cancelled.' }, + { status: 499 } + ) + } logger.error(`[${requestId}] Error publishing video to TikTok:`, error) return NextResponse.json( { success: false, error: getErrorMessage(error, 'Internal server error') }, diff --git a/apps/sim/app/api/tools/tiktok/publish-video/upload.test.ts b/apps/sim/app/api/tools/tiktok/publish-video/upload.test.ts new file mode 100644 index 00000000000..bc4f402f176 --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/upload.test.ts @@ -0,0 +1,195 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' + +const { mockBackoffWithJitter, mockDownloadFileStream, mockHeadObject, mockParseRetryAfter } = + vi.hoisted(() => ({ + mockBackoffWithJitter: vi.fn(() => 0), + mockDownloadFileStream: vi.fn(), + mockHeadObject: vi.fn(), + mockParseRetryAfter: vi.fn(() => 25), + })) + +vi.mock('@sim/utils/retry', () => ({ + backoffWithJitter: mockBackoffWithJitter, + parseRetryAfter: mockParseRetryAfter, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mockDownloadFileStream, + headObject: mockHeadObject, +})) + +import { + computeTikTokChunkPlan, + getStoredVideoSize, + streamStoredVideoToTikTok, + TIKTOK_MAX_VIDEO_BYTES, +} from '@/app/api/tools/tiktok/publish-video/upload' + +const baseStreamOptions = { + key: 'workspace/workspace-1/video.mp4', + context: 'workspace' as const, + uploadUrl: 'https://upload.example/video', + mimeType: 'video/mp4', + requestId: 'request-1', +} + +describe('TikTok video upload streaming', () => { + beforeEach(() => { + vi.clearAllMocks() + mockBackoffWithJitter.mockReturnValue(0) + mockParseRetryAfter.mockReturnValue(25) + }) + + it('uses provider metadata as the authoritative bounded size', async () => { + mockHeadObject.mockResolvedValue({ size: 1234, contentType: 'video/mp4' }) + + await expect( + getStoredVideoSize({ + key: baseStreamOptions.key, + context: baseStreamOptions.context, + signal: new AbortController().signal, + }) + ).resolves.toBe(1234) + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) + + it('counts a stream without accumulating it when provider metadata is unavailable', async () => { + mockHeadObject.mockResolvedValue(null) + mockDownloadFileStream.mockResolvedValue( + Readable.from([Buffer.alloc(3), Buffer.alloc(5), Buffer.alloc(7)]) + ) + + await expect( + getStoredVideoSize({ + key: baseStreamOptions.key, + context: baseStreamOptions.context, + signal: new AbortController().signal, + }) + ).resolves.toBe(15) + }) + + it('rejects an oversized provider object before opening its body', async () => { + mockHeadObject.mockResolvedValue({ size: TIKTOK_MAX_VIDEO_BYTES + 1 }) + + await expect( + getStoredVideoSize({ + key: baseStreamOptions.key, + context: baseStreamOptions.context, + signal: new AbortController().signal, + }) + ).rejects.toBeInstanceOf(PayloadSizeLimitError) + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) + + it('computes TikTok chunk counts with the final chunk absorbing the remainder', () => { + expect(computeTikTokChunkPlan(4_000_000)).toEqual({ + chunkSize: 4_000_000, + totalChunkCount: 1, + }) + expect(computeTikTokChunkPlan(20_000_001)).toEqual({ + chunkSize: 10_000_000, + totalChunkCount: 2, + }) + }) + + it('streams sequential chunks with exact 206 intermediate and 201 final ranges', async () => { + const totalBytes = 20_000_001 + mockDownloadFileStream.mockResolvedValue( + Readable.from([Buffer.alloc(7_000_000, 1), Buffer.alloc(13_000_001, 2)]) + ) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 206 })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + + await streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes, + signal: controller.signal, + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: 'PUT', + signal: controller.signal, + headers: { + 'Content-Length': '10000000', + 'Content-Range': 'bytes 0-9999999/20000001', + 'Content-Type': 'video/mp4', + }, + }) + expect(fetchMock.mock.calls[1][1]).toMatchObject({ + method: 'PUT', + signal: controller.signal, + headers: { + 'Content-Length': '10000001', + 'Content-Range': 'bytes 10000000-20000000/20000001', + 'Content-Type': 'video/mp4', + }, + }) + }) + + it('retries only 5xx responses with the same bounded chunk', async () => { + mockDownloadFileStream.mockResolvedValue(Readable.from([Buffer.from('video')])) + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response('temporary one', { status: 500, headers: { 'Retry-After': '1' } }) + ) + .mockResolvedValueOnce(new Response('temporary two', { status: 503 })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + vi.stubGlobal('fetch', fetchMock) + + await streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes: 5, + signal: new AbortController().signal, + }) + + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(mockParseRetryAfter).toHaveBeenCalledTimes(2) + expect(mockBackoffWithJitter).toHaveBeenNthCalledWith(1, 1, 25) + expect(mockBackoffWithJitter).toHaveBeenNthCalledWith(2, 2, 25) + const uploadedBodies = fetchMock.mock.calls.map((call) => + Buffer.from(call[1]?.body as Uint8Array).toString('utf8') + ) + expect(uploadedBodies).toEqual(['video', 'video', 'video']) + }) + + it('rejects a successful but protocol-invalid final status without retrying', async () => { + mockDownloadFileStream.mockResolvedValue(Readable.from([Buffer.from('video')])) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes: 5, + signal: new AbortController().signal, + }) + ).rejects.toThrow('expected HTTP 201, received HTTP 200') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('detects storage-size drift before sending the final chunk', async () => { + mockDownloadFileStream.mockResolvedValue(Readable.from([Buffer.from('video-extra')])) + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect( + streamStoredVideoToTikTok({ + ...baseStreamOptions, + totalBytes: 5, + signal: new AbortController().signal, + }) + ).rejects.toThrow('Stored video grew after its size was resolved') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/tools/tiktok/publish-video/upload.ts b/apps/sim/app/api/tools/tiktok/publish-video/upload.ts new file mode 100644 index 00000000000..7ec6d5687fc --- /dev/null +++ b/apps/sim/app/api/tools/tiktok/publish-video/upload.ts @@ -0,0 +1,311 @@ +import type { Readable } from 'node:stream' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' +import { + assertKnownSizeWithinLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import type { StorageContext } from '@/lib/uploads/config' +import { downloadFileStream, headObject } from '@/lib/uploads/core/storage-service' + +const logger = createLogger('TikTokVideoUpload') + +/** TikTok accepts 5-64 MB chunks and allows the final chunk to absorb the remainder. */ +const DEFAULT_CHUNK_SIZE = 10_000_000 +const MAX_UPLOAD_ATTEMPTS = 3 +const ERROR_RESPONSE_MAX_BYTES = 64 * 1024 + +/** + * Sim intentionally applies a lower relay ceiling than TikTok's provider limit. This bounds + * request duration and the current-chunk retry buffer while uploads run in the web process. + */ +export const TIKTOK_MAX_VIDEO_BYTES = 250 * 1024 * 1024 + +export interface TikTokChunkPlan { + chunkSize: number + totalChunkCount: number +} + +interface StoredFileOptions { + key: string + context: StorageContext + signal: AbortSignal +} + +interface StreamStoredVideoOptions extends StoredFileOptions { + uploadUrl: string + totalBytes: number + mimeType: string + requestId: string +} + +function abortError(signal: AbortSignal): Error { + return toError(signal.reason ?? new Error('Request aborted')) +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw abortError(signal) +} + +function bindStreamToAbort(stream: Readable, signal: AbortSignal): () => void { + let handled = false + const onAbort = () => { + if (handled) return + handled = true + stream.destroy(abortError(signal)) + } + if (signal.aborted) { + onAbort() + } else { + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + } + return () => signal.removeEventListener('abort', onAbort) +} + +async function sleepWithAbort(delayMs: number, signal: AbortSignal): Promise { + throwIfAborted(signal) + + let onAbort: (() => void) | undefined + const aborted = new Promise((_, reject) => { + let handled = false + onAbort = () => { + if (handled) return + handled = true + reject(abortError(signal)) + } + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + }) + + try { + await Promise.race([sleep(delayMs), aborted]) + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort) + } +} + +function normalizeStreamChunk(value: unknown): Buffer { + if (Buffer.isBuffer(value)) return value + if (value instanceof Uint8Array || typeof value === 'string') return Buffer.from(value) + throw new Error('Storage returned an unsupported video stream chunk') +} + +/** Computes TikTok's declared FILE_UPLOAD chunk layout without reading the file. */ +export function computeTikTokChunkPlan(totalBytes: number): TikTokChunkPlan { + if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) { + throw new Error('TikTok video size must be a positive safe integer') + } + + if (totalBytes <= DEFAULT_CHUNK_SIZE) { + return { chunkSize: totalBytes, totalChunkCount: 1 } + } + + return { + chunkSize: DEFAULT_CHUNK_SIZE, + totalChunkCount: Math.floor(totalBytes / DEFAULT_CHUNK_SIZE), + } +} + +function validateStoredSize(size: number): number { + if (!Number.isSafeInteger(size) || size < 0) { + throw new Error('Storage returned an invalid video size') + } + assertKnownSizeWithinLimit(size, TIKTOK_MAX_VIDEO_BYTES, 'TikTok video upload') + return size +} + +async function countStoredFileBytes(options: StoredFileOptions): Promise { + throwIfAborted(options.signal) + const stream = await downloadFileStream({ key: options.key, context: options.context }) + const unbindAbort = bindStreamToAbort(stream, options.signal) + let totalBytes = 0 + + try { + for await (const value of stream) { + throwIfAborted(options.signal) + totalBytes += normalizeStreamChunk(value).byteLength + assertKnownSizeWithinLimit(totalBytes, TIKTOK_MAX_VIDEO_BYTES, 'TikTok video upload') + } + throwIfAborted(options.signal) + return validateStoredSize(totalBytes) + } finally { + unbindAbort() + stream.destroy() + } +} + +/** + * Resolves the authoritative object size. Cloud storage uses provider metadata; local storage + * and providers without HEAD support are counted with a bounded, zero-accumulation pass. + */ +export async function getStoredVideoSize(options: StoredFileOptions): Promise { + throwIfAborted(options.signal) + const metadata = await headObject(options.key, options.context) + throwIfAborted(options.signal) + + return metadata ? validateStoredSize(metadata.size) : countStoredFileBytes(options) +} + +class ExactStreamReader { + private readonly iterator: AsyncIterator + private pending: Buffer | null = null + private pendingOffset = 0 + + constructor(stream: Readable) { + this.iterator = stream[Symbol.asyncIterator]() + } + + private async nextNonEmptyChunk(signal: AbortSignal): Promise { + while (true) { + throwIfAborted(signal) + const next = await this.iterator.next() + throwIfAborted(signal) + if (next.done) return null + const chunk = normalizeStreamChunk(next.value) + if (chunk.byteLength > 0) return chunk + } + } + + async readExactly(byteLength: number, signal: AbortSignal): Promise { + const output = Buffer.allocUnsafe(byteLength) + let written = 0 + + while (written < byteLength) { + if (!this.pending || this.pendingOffset >= this.pending.byteLength) { + this.pending = await this.nextNonEmptyChunk(signal) + this.pendingOffset = 0 + if (!this.pending) { + throw new Error(`Stored video ended early: expected ${byteLength - written} more byte(s)`) + } + } + + const available = this.pending.byteLength - this.pendingOffset + const copyLength = Math.min(available, byteLength - written) + this.pending.copy(output, written, this.pendingOffset, this.pendingOffset + copyLength) + this.pendingOffset += copyLength + written += copyLength + } + + return output + } + + async assertExhausted(signal: AbortSignal): Promise { + if (this.pending && this.pendingOffset < this.pending.byteLength) { + throw new Error('Stored video grew after its size was resolved') + } + if (await this.nextNonEmptyChunk(signal)) { + throw new Error('Stored video grew after its size was resolved') + } + } + + async close(): Promise { + await this.iterator.return?.() + } +} + +async function readUploadResponse(response: Response, signal: AbortSignal): Promise { + try { + return await readResponseTextWithLimit(response, { + maxBytes: ERROR_RESPONSE_MAX_BYTES, + label: 'TikTok upload response', + signal, + }) + } catch { + throwIfAborted(signal) + return 'Response body exceeded the allowed size' + } +} + +async function uploadChunk(options: { + uploadUrl: string + chunk: Buffer + chunkIndex: number + totalChunkCount: number + start: number + end: number + totalBytes: number + mimeType: string + requestId: string + signal: AbortSignal +}): Promise { + const expectedStatus = options.chunkIndex === options.totalChunkCount - 1 ? 201 : 206 + + for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt++) { + throwIfAborted(options.signal) + const response = await fetch(options.uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': options.mimeType, + 'Content-Length': String(options.chunk.byteLength), + 'Content-Range': `bytes ${options.start}-${options.end}/${options.totalBytes}`, + }, + body: new Uint8Array(options.chunk), + signal: options.signal, + }) + + if (response.status === expectedStatus) { + await readUploadResponse(response, options.signal) + return + } + + const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) + const errorText = await readUploadResponse(response, options.signal) + const retryable = response.status >= 500 && response.status <= 599 + + if (retryable && attempt < MAX_UPLOAD_ATTEMPTS) { + logger.warn(`[${options.requestId}] Retrying TikTok video chunk`, { + chunkIndex: options.chunkIndex, + attempt, + status: response.status, + }) + await sleepWithAbort(backoffWithJitter(attempt, retryAfterMs), options.signal) + continue + } + + throw new Error( + `TikTok rejected video chunk ${options.chunkIndex + 1}/${options.totalChunkCount}: expected HTTP ${expectedStatus}, received HTTP ${response.status}${errorText ? ` ${errorText}` : ''}` + ) + } +} + +/** + * Streams a stored video into TikTok sequentially. Only the current provider chunk is retained, + * so retry memory is bounded independently of total file size. + */ +export async function streamStoredVideoToTikTok(options: StreamStoredVideoOptions): Promise { + const plan = computeTikTokChunkPlan(options.totalBytes) + throwIfAborted(options.signal) + const stream = await downloadFileStream({ key: options.key, context: options.context }) + const unbindAbort = bindStreamToAbort(stream, options.signal) + const reader = new ExactStreamReader(stream) + + try { + for (let chunkIndex = 0; chunkIndex < plan.totalChunkCount; chunkIndex++) { + const start = chunkIndex * plan.chunkSize + const isLastChunk = chunkIndex === plan.totalChunkCount - 1 + const end = isLastChunk ? options.totalBytes - 1 : start + plan.chunkSize - 1 + const chunk = await reader.readExactly(end - start + 1, options.signal) + if (isLastChunk) await reader.assertExhausted(options.signal) + + await uploadChunk({ + uploadUrl: options.uploadUrl, + chunk, + chunkIndex, + totalChunkCount: plan.totalChunkCount, + start, + end, + totalBytes: options.totalBytes, + mimeType: options.mimeType, + requestId: options.requestId, + signal: options.signal, + }) + } + } finally { + unbindAbort() + stream.destroy() + await reader.close().catch(() => {}) + } +} diff --git a/apps/sim/app/api/webhooks/tiktok/route.test.ts b/apps/sim/app/api/webhooks/tiktok/route.test.ts index 71d07947cab..6ad1f0676eb 100644 --- a/apps/sim/app/api/webhooks/tiktok/route.test.ts +++ b/apps/sim/app/api/webhooks/tiktok/route.test.ts @@ -6,16 +6,13 @@ import crypto from 'node:crypto' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockEnqueue, mockExecuteTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({ - mockEnqueue: vi.fn(), - mockExecuteTikTokWebhookIngress: vi.fn(), +const { mockEnqueueTikTokWebhookIngress, mockRelease } = vi.hoisted(() => ({ + mockEnqueueTikTokWebhookIngress: vi.fn(), mockRelease: vi.fn(), })) vi.mock('@/background/tiktok-webhook-ingress', () => ({ - executeTikTokWebhookIngress: mockExecuteTikTokWebhookIngress, - TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT: 50, - TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS: 3, + enqueueTikTokWebhookIngress: mockEnqueueTikTokWebhookIngress, })) vi.mock('@/lib/core/admission/gate', () => ({ @@ -23,10 +20,6 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: vi.fn(() => ({ release: mockRelease })), })) -vi.mock('@/lib/core/async-jobs', () => ({ - getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })), -})) - vi.mock('@/lib/core/config/env', () => ({ env: { TIKTOK_CLIENT_ID: 'client-key', @@ -73,7 +66,7 @@ function signedRequest(overrides?: { clientKey?: string }): NextRequest { describe('TikTok webhook ingress route', () => { beforeEach(() => { vi.clearAllMocks() - mockEnqueue.mockResolvedValue('ingress-job-1') + mockEnqueueTikTokWebhookIngress.mockResolvedValue('ingress-job-1') }) it('returns 200 only after the verified delivery is accepted by the job queue', async () => { @@ -81,25 +74,20 @@ describe('TikTok webhook ingress route', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ ok: true }) - expect(mockEnqueue).toHaveBeenCalledWith( - 'tiktok-webhook-ingress', + expect(mockEnqueueTikTokWebhookIngress).toHaveBeenCalledWith( expect.objectContaining({ envelope: expect.objectContaining({ client_key: 'client-key', user_openid: 'act.user', }), requestId: 'request-1', - }), - expect.objectContaining({ - maxAttempts: 3, - runner: expect.any(Function), }) ) expect(mockRelease).toHaveBeenCalledOnce() }) it('returns 503 when durable acceptance fails so TikTok retries', async () => { - mockEnqueue.mockRejectedValue(new Error('queue unavailable')) + mockEnqueueTikTokWebhookIngress.mockRejectedValue(new Error('queue unavailable')) const response = await POST(signedRequest()) @@ -111,6 +99,6 @@ describe('TikTok webhook ingress route', () => { const response = await POST(signedRequest({ clientKey: 'other-client-key' })) expect(response.status).toBe(401) - expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockEnqueueTikTokWebhookIngress).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/webhooks/tiktok/route.ts b/apps/sim/app/api/webhooks/tiktok/route.ts index 5fecb9134d0..b7f79860586 100644 --- a/apps/sim/app/api/webhooks/tiktok/route.ts +++ b/apps/sim/app/api/webhooks/tiktok/route.ts @@ -3,7 +3,6 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { tiktokWebhookEnvelopeSchema } from '@/lib/api/contracts/webhooks' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' -import { getJobQueue } from '@/lib/core/async-jobs' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -15,9 +14,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok' import { - executeTikTokWebhookIngress, - TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, - TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS, + enqueueTikTokWebhookIngress, type TikTokWebhookIngressPayload, } from '@/background/tiktok-webhook-ingress' @@ -107,15 +104,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requestId, receivedAt, } - const jobQueue = await getJobQueue() - const jobId = await jobQueue.enqueue('tiktok-webhook-ingress', payload, { - maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS, - concurrencyKey: 'tiktok-webhook-ingress', - concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, - runner: async () => { - await executeTikTokWebhookIngress(payload) - }, - }) + const jobId = await enqueueTikTokWebhookIngress(payload) logger.info(`[${requestId}] Accepted TikTok webhook delivery`, { event: envelope.event, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 205db37466c..4b0368813d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -19,6 +19,7 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { + useCloudStorageConfigured, useUploadWorkspaceFile, useWorkspaceFiles, workspaceFilesKeys, @@ -36,6 +37,11 @@ interface FileUploadProps { maxSize?: number // in MB acceptedTypes?: string // comma separated MIME types multiple?: boolean // whether to allow multiple file uploads + /** + * When true, disable new uploads and show a notice if S3/Blob is not configured + * (providers that need a public HTTPS URL Meta can fetch, e.g. Instagram). + */ + requiresCloudStorage?: boolean isPreview?: boolean previewValue?: any | null disabled?: boolean @@ -170,6 +176,7 @@ export function FileUpload({ maxSize = 10, // Default 10MB acceptedTypes = '*', multiple = false, // Default to single file for backward compatibility + requiresCloudStorage = false, isPreview = false, previewValue, disabled = false, @@ -212,6 +219,15 @@ export function FileUpload({ refetch: refetchWorkspaceFiles, } = useWorkspaceFiles(isPreview ? '' : workspaceId) + const { data: cloudConfigured, isLoading: loadingCloudStatus } = useCloudStorageConfigured( + requiresCloudStorage && !isPreview + ) + // Fail closed: block until the status check succeeds with true. Loading, errors, and + // explicit false all leave cloudConfigured !== true (avoid Meta-unfetchable files). + const cloudUploadBlocked = requiresCloudStorage && cloudConfigured !== true + const showCloudStorageWarning = + requiresCloudStorage && !loadingCloudStatus && cloudConfigured !== true + const uploadFileMutation = useUploadWorkspaceFile() const queryClient = useQueryClient() @@ -278,7 +294,7 @@ export function FileUpload({ e.preventDefault() e.stopPropagation() - if (disabled) return + if (disabled || cloudUploadBlocked) return if (fileInputRef.current) { fileInputRef.current.value = '' @@ -308,7 +324,7 @@ export function FileUpload({ * Handles file upload when new file(s) are selected */ const handleFileChange = async (e: React.ChangeEvent) => { - if (isPreview || disabled) return + if (isPreview || disabled || cloudUploadBlocked) return e.stopPropagation() @@ -459,6 +475,8 @@ export function FileUpload({ * Handle selecting an existing workspace file */ const handleSelectWorkspaceFile = (fileId: string) => { + if (cloudUploadBlocked) return + const selectedFile = workspaceFiles.find((f) => f.id === fileId) if (!selectedFile) return @@ -601,35 +619,36 @@ export function FileUpload({ // Options for multiple file mode (filters out already selected files) const comboboxOptions = useMemo( () => [ - { label: 'Upload New File', value: '__upload_new__' }, + { label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked }, ...availableWorkspaceFiles.map((file) => { const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) return { label: file.name, value: file.id, - disabled: !isAccepted, + // When cloud is required, local workspace files are also unpublishable. + disabled: !isAccepted || cloudUploadBlocked, } }), ], - [availableWorkspaceFiles, acceptedTypes] + [availableWorkspaceFiles, acceptedTypes, cloudUploadBlocked] ) // Options for single file mode (includes all files, selected one will be highlighted) const singleFileOptions = useMemo( () => [ - { label: 'Upload New File', value: '__upload_new__' }, + { label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked }, ...workspaceFiles.map((file) => { const isAccepted = !acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes) return { label: file.name, value: file.id, - disabled: !isAccepted, + disabled: !isAccepted || cloudUploadBlocked, } }), ], - [workspaceFiles, acceptedTypes] + [workspaceFiles, acceptedTypes, cloudUploadBlocked] ) // Find the selected file's workspace ID for highlighting in single file mode @@ -667,6 +686,7 @@ export function FileUpload({ setInputValue('') if (value === '__upload_new__') { + if (cloudUploadBlocked) return handleOpenFileDialog({ preventDefault: () => {}, stopPropagation: () => {}, @@ -688,6 +708,13 @@ export function FileUpload({ data-testid='file-input-element' /> + {showCloudStorageWarning && ( +
+ Cloud storage (S3 or Blob) is required for file uploads. Configure S3_BUCKET_NAME and + AWS_REGION, or Azure Blob env vars. +
+ )} + {/* Error message */} {uploadError &&
{uploadError}
} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 7ed1f9806f6..e8752f0c4aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -906,6 +906,7 @@ function SubBlockComponent({ acceptedTypes={config.acceptedTypes || '*'} multiple={config.multiple === true} maxSize={config.maxSize} + requiresCloudStorage={config.requiresCloudStorage === true} isPreview={isPreview} previewValue={previewValue as any} disabled={isDisabled} diff --git a/apps/sim/background/tiktok-webhook-ingress.test.ts b/apps/sim/background/tiktok-webhook-ingress.test.ts index 7673053bb95..4266347cf74 100644 --- a/apps/sim/background/tiktok-webhook-ingress.test.ts +++ b/apps/sim/background/tiktok-webhook-ingress.test.ts @@ -4,10 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDispatchResolvedWebhookTarget, mockFindTikTokWebhookTargets } = vi.hoisted(() => ({ - mockDispatchResolvedWebhookTarget: vi.fn(), - mockFindTikTokWebhookTargets: vi.fn(), -})) +const { mockDispatchResolvedWebhookTarget, mockEnqueue, mockFindTikTokWebhookTargetPage } = + vi.hoisted(() => ({ + mockDispatchResolvedWebhookTarget: vi.fn(), + mockEnqueue: vi.fn(), + mockFindTikTokWebhookTargetPage: vi.fn(), + })) vi.mock('@trigger.dev/sdk', () => ({ task: vi.fn((config: unknown) => config), @@ -17,11 +19,15 @@ vi.mock('@/lib/webhooks/processor', () => ({ dispatchResolvedWebhookTarget: mockDispatchResolvedWebhookTarget, })) -vi.mock('@/lib/webhooks/providers/tiktok-targets', () => ({ - findTikTokWebhookTargets: mockFindTikTokWebhookTargets, +vi.mock('@/background/tiktok-webhook-targets', () => ({ + findTikTokWebhookTargetPage: mockFindTikTokWebhookTargetPage, +})) +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })), })) import { + enqueueTikTokWebhookIngress, executeTikTokWebhookIngress, type TikTokWebhookIngressPayload, } from '@/background/tiktok-webhook-ingress' @@ -39,24 +45,27 @@ const payload: TikTokWebhookIngressPayload = { receivedAt: 1_725_000_000_000, } -const targets = [ - { - webhook: { id: 'webhook-1', path: 'tiktok', provider: 'tiktok' }, - workflow: { id: 'workflow-1' }, - }, - { - webhook: { id: 'webhook-2', path: 'tiktok', provider: 'tiktok' }, - workflow: { id: 'workflow-2' }, - }, -] +const firstTarget = { + webhook: { id: 'webhook-1', path: 'tiktok', provider: 'tiktok' }, + workflow: { id: 'workflow-1' }, +} +const secondTarget = { + webhook: { id: 'webhook-2', path: 'tiktok', provider: 'tiktok' }, + workflow: { id: 'workflow-2' }, +} describe('executeTikTokWebhookIngress', () => { beforeEach(() => { vi.clearAllMocks() + mockEnqueue.mockResolvedValue('ingress-job-1') }) it('acknowledges deliveries without active targets', async () => { - mockFindTikTokWebhookTargets.mockResolvedValue([]) + mockFindTikTokWebhookTargetPage.mockResolvedValue({ + hasMore: false, + nextCursor: null, + targets: [], + }) await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({ ignored: 0, @@ -66,44 +75,99 @@ describe('executeTikTokWebhookIngress', () => { expect(mockDispatchResolvedWebhookTarget).not.toHaveBeenCalled() }) - it('dispatches every resolved target and reports typed outcomes', async () => { - mockFindTikTokWebhookTargets.mockResolvedValue(targets) - mockDispatchResolvedWebhookTarget - .mockResolvedValueOnce({ + it('dispatches one bounded page and returns its continuation cursor', async () => { + const events: string[] = [] + mockFindTikTokWebhookTargetPage.mockImplementationOnce(async () => { + events.push('page-1') + return { + hasMore: true, + nextCursor: 'webhook-1', + targets: [firstTarget], + } + }) + mockDispatchResolvedWebhookTarget.mockImplementationOnce(async () => { + events.push('dispatch-1') + return { outcome: 'queued', reason: 'queued', response: new Response(null, { status: 200 }), - }) - .mockResolvedValueOnce({ - outcome: 'ignored', - reason: 'event-mismatch', - response: new Response(null, { status: 200 }), - }) + } + }) await expect(executeTikTokWebhookIngress(payload)).resolves.toEqual({ - ignored: 1, + ignored: 0, + nextCursor: 'webhook-1', processed: 1, - targetCount: 2, + targetCount: 1, }) - expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2) + expect(events).toEqual(['page-1', 'dispatch-1']) + expect(mockFindTikTokWebhookTargetPage).toHaveBeenNthCalledWith( + 1, + 'act.user', + 'request-1', + undefined + ) + }) + + it('enqueues the next page only after the current page succeeds', async () => { + mockFindTikTokWebhookTargetPage.mockResolvedValue({ + hasMore: true, + nextCursor: 'webhook-1', + targets: [firstTarget], + }) + mockDispatchResolvedWebhookTarget.mockResolvedValue({ + outcome: 'queued', + reason: 'queued', + response: new Response(null, { status: 200 }), + }) + + await enqueueTikTokWebhookIngress(payload) + const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } + await options.runner() + + expect(mockEnqueue).toHaveBeenNthCalledWith( + 2, + 'tiktok-webhook-ingress', + expect.objectContaining({ afterWebhookId: 'webhook-1' }), + expect.objectContaining({ + jobId: 'tiktok-webhook-ingress:request-1:webhook-1', + }) + ) }) it('throws after a target failure so the durable ingress job retries', async () => { - mockFindTikTokWebhookTargets.mockResolvedValue(targets) + mockFindTikTokWebhookTargetPage.mockResolvedValueOnce({ + hasMore: false, + nextCursor: 'webhook-2', + targets: [firstTarget, secondTarget], + }) mockDispatchResolvedWebhookTarget - .mockResolvedValueOnce({ - outcome: 'queued', - reason: 'queued', - response: new Response(null, { status: 200 }), - }) .mockResolvedValueOnce({ outcome: 'failed', reason: 'queue-failed', response: new Response(null, { status: 500 }), }) + .mockResolvedValueOnce({ + outcome: 'queued', + reason: 'queued', + response: new Response(null, { status: 200 }), + }) await expect(executeTikTokWebhookIngress(payload)).rejects.toThrow( 'Failed to dispatch 1 of 2 TikTok webhook targets' ) + expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(2) + }) + + it('fails closed if a full target page does not provide a forward cursor', async () => { + mockFindTikTokWebhookTargetPage.mockResolvedValue({ + hasMore: true, + nextCursor: null, + targets: [], + }) + + await expect(executeTikTokWebhookIngress(payload)).rejects.toThrow( + 'TikTok webhook target pagination did not advance' + ) }) }) diff --git a/apps/sim/background/tiktok-webhook-ingress.ts b/apps/sim/background/tiktok-webhook-ingress.ts index d5b4bfb9480..42235b20651 100644 --- a/apps/sim/background/tiktok-webhook-ingress.ts +++ b/apps/sim/background/tiktok-webhook-ingress.ts @@ -2,8 +2,9 @@ import { createLogger } from '@sim/logger' import { task } from '@trigger.dev/sdk' import { NextRequest } from 'next/server' import type { TikTokWebhookEnvelope } from '@/lib/api/contracts/webhooks' +import { getJobQueue } from '@/lib/core/async-jobs' import { dispatchResolvedWebhookTarget } from '@/lib/webhooks/processor' -import { findTikTokWebhookTargets } from '@/lib/webhooks/providers/tiktok-targets' +import { findTikTokWebhookTargetPage } from '@/background/tiktok-webhook-targets' const logger = createLogger('TikTokWebhookIngressTask') @@ -11,6 +12,7 @@ export const TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT = 50 export const TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS = 3 export interface TikTokWebhookIngressPayload { + afterWebhookId?: string envelope: TikTokWebhookEnvelope headers: { 'content-type': string @@ -21,38 +23,41 @@ export interface TikTokWebhookIngressPayload { export interface TikTokWebhookIngressResult { ignored: number + nextCursor?: string processed: number targetCount: number } /** - * Resolves and dispatches all active workflow targets for one verified TikTok delivery. Throwing - * after any retryable target failure lets Trigger.dev replay the fanout; workflow-level - * idempotency prevents already-queued targets from executing twice. + * Resolves and dispatches one fixed-size keyset page for a verified TikTok delivery. Each page is a + * separate durable job, so retries replay at most one bounded page and successful pages continue + * from their last webhook ID. */ export async function executeTikTokWebhookIngress( payload: TikTokWebhookIngressPayload ): Promise { - const targets = await findTikTokWebhookTargets(payload.envelope.user_openid, payload.requestId) - if (targets.length === 0) { - logger.info(`[${payload.requestId}] No TikTok webhook targets found`, { - event: payload.envelope.event, - userOpenIdPrefix: payload.envelope.user_openid.slice(0, 12), - }) - return { ignored: 0, processed: 0, targetCount: 0 } - } - const request = new NextRequest('http://internal/api/webhooks/tiktok', { method: 'POST', headers: payload.headers, body: JSON.stringify(payload.envelope), }) + const page = await findTikTokWebhookTargetPage( + payload.envelope.user_openid, + payload.requestId, + payload.afterWebhookId + ) + const nextCursor = page.hasMore ? page.nextCursor : null + if (page.hasMore && (!nextCursor || nextCursor === payload.afterWebhookId)) { + throw new Error('TikTok webhook target pagination did not advance') + } + let ignored = 0 let processed = 0 let failed = 0 + const targetCount = page.targets.length - for (const { webhook, workflow } of targets) { + for (const { webhook, workflow } of page.targets) { const result = await dispatchResolvedWebhookTarget( webhook, workflow, @@ -76,17 +81,55 @@ export async function executeTikTokWebhookIngress( } if (failed > 0) { - throw new Error(`Failed to dispatch ${failed} of ${targets.length} TikTok webhook targets`) + throw new Error(`Failed to dispatch ${failed} of ${targetCount} TikTok webhook targets`) } - logger.info(`[${payload.requestId}] TikTok webhook fanout completed`, { + if (targetCount === 0) { + logger.info(`[${payload.requestId}] No TikTok webhook targets found in page`, { + event: payload.envelope.event, + userOpenIdPrefix: payload.envelope.user_openid.slice(0, 12), + }) + return { + ignored: 0, + processed: 0, + targetCount: 0, + ...(nextCursor ? { nextCursor } : {}), + } + } + + logger.info(`[${payload.requestId}] TikTok webhook fanout page completed`, { event: payload.envelope.event, ignored, + nextCursor, processed, - targetCount: targets.length, + targetCount, }) - return { ignored, processed, targetCount: targets.length } + return { ignored, processed, targetCount, ...(nextCursor ? { nextCursor } : {}) } +} + +async function runTikTokWebhookIngressJob(payload: TikTokWebhookIngressPayload): Promise { + const result = await executeTikTokWebhookIngress(payload) + if (!result.nextCursor) return + + await enqueueTikTokWebhookIngress({ + ...payload, + afterWebhookId: result.nextCursor, + }) +} + +/** Enqueues one bounded TikTok webhook fanout page with stable continuation identity. */ +export async function enqueueTikTokWebhookIngress( + payload: TikTokWebhookIngressPayload +): Promise { + const jobQueue = await getJobQueue() + return jobQueue.enqueue('tiktok-webhook-ingress', payload, { + jobId: `tiktok-webhook-ingress:${payload.requestId}:${payload.afterWebhookId ?? 'root'}`, + maxAttempts: TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS, + concurrencyKey: 'tiktok-webhook-ingress', + concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, + runner: async () => runTikTokWebhookIngressJob(payload), + }) } export const tiktokWebhookIngressTask = task({ @@ -101,5 +144,5 @@ export const tiktokWebhookIngressTask = task({ queue: { concurrencyLimit: TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, }, - run: async (payload: TikTokWebhookIngressPayload) => executeTikTokWebhookIngress(payload), + run: async (payload: TikTokWebhookIngressPayload) => runTikTokWebhookIngressJob(payload), }) diff --git a/apps/sim/background/tiktok-webhook-targets.test.ts b/apps/sim/background/tiktok-webhook-targets.test.ts new file mode 100644 index 00000000000..d8acfbf1299 --- /dev/null +++ b/apps/sim/background/tiktok-webhook-targets.test.ts @@ -0,0 +1,193 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAsc, + mockCredentialExpression, + mockEq, + mockGt, + mockLike, + mockLimit, + mockOrderBy, + mockSelect, + queryRows, +} = vi.hoisted(() => ({ + mockAsc: vi.fn((value: unknown) => ({ asc: value })), + mockCredentialExpression: vi.fn(() => 'webhook.credentialId'), + mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })), + mockGt: vi.fn((left: unknown, right: unknown) => ({ gt: [left, right] })), + mockLike: vi.fn((left: unknown, right: unknown) => ({ left, right })), + mockLimit: vi.fn(), + mockOrderBy: vi.fn(), + mockSelect: vi.fn(), + queryRows: { + rows: [] as Array<{ + accountId: string + webhookId: string + webhook: Record + workflow: Record + }>, + }, +})) + +vi.mock('@sim/db', () => { + const chain = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + leftJoin: vi.fn(() => chain), + where: vi.fn(() => chain), + orderBy: mockOrderBy, + limit: mockLimit, + } + mockOrderBy.mockImplementation(() => chain) + mockLimit.mockImplementation((limit: number) => Promise.resolve(queryRows.rows.slice(0, limit))) + mockSelect.mockImplementation(() => chain) + + return { + account: { + id: 'account.id', + accountId: 'account.accountId', + providerId: 'account.providerId', + }, + credential: { + id: 'credential.id', + accountId: 'credential.accountId', + providerId: 'credential.providerId', + type: 'credential.type', + workspaceId: 'credential.workspaceId', + }, + db: { select: mockSelect }, + webhookCredentialIdExpression: mockCredentialExpression, + webhook: { + deploymentVersionId: 'webhook.deploymentVersionId', + isActive: 'webhook.isActive', + id: 'webhook.id', + archivedAt: 'webhook.archivedAt', + provider: 'webhook.provider', + providerConfig: 'webhook.providerConfig', + workflowId: 'webhook.workflowId', + }, + workflow: { + id: 'workflow.id', + workspaceId: 'workflow.workspaceId', + archivedAt: 'workflow.archivedAt', + }, + workflowDeploymentVersion: { + id: 'workflowDeploymentVersion.id', + workflowId: 'workflowDeploymentVersion.workflowId', + isActive: 'workflowDeploymentVersion.isActive', + }, + } +}) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => conditions), + asc: mockAsc, + eq: mockEq, + gt: mockGt, + isNull: vi.fn((value: unknown) => ({ isNull: value })), + like: mockLike, + or: vi.fn((...conditions: unknown[]) => conditions), +})) + +import { + findTikTokWebhookTargetPage, + TIKTOK_WEBHOOK_TARGET_PAGE_SIZE, +} from '@/background/tiktok-webhook-targets' + +const ACCOUNT_UUID = '11111111-2222-3333-4444-555555555555' + +describe('findTikTokWebhookTargetPage', () => { + beforeEach(() => { + vi.clearAllMocks() + queryRows.rows = [] + }) + + it('returns only rows whose stored account ID exactly matches user_openid', async () => { + queryRows.rows = [ + { + accountId: `act.user-${ACCOUNT_UUID}`, + webhookId: 'webhook-1', + webhook: { id: 'webhook-1' }, + workflow: { id: 'workflow-1' }, + }, + { + accountId: `act.user-other-${ACCOUNT_UUID}`, + webhookId: 'webhook-2', + webhook: { id: 'webhook-2' }, + workflow: { id: 'workflow-2' }, + }, + ] + + const page = await findTikTokWebhookTargetPage('act.user', 'request-1') + + expect(page).toEqual({ + hasMore: false, + nextCursor: 'webhook-2', + targets: [ + { + webhook: { id: 'webhook-1' }, + workflow: { id: 'workflow-1' }, + }, + ], + }) + }) + + it('enforces provider and workspace bindings in the database query', async () => { + await findTikTokWebhookTargetPage('act.user', 'request-2') + + expect(mockEq).toHaveBeenCalledWith('credential.providerId', 'tiktok') + expect(mockEq).toHaveBeenCalledWith('webhook.provider', 'tiktok') + expect(mockEq).toHaveBeenCalledWith('workflow.workspaceId', 'credential.workspaceId') + expect(mockCredentialExpression).toHaveBeenCalledWith('webhook.providerConfig') + expect(mockEq).toHaveBeenCalledWith('webhook.credentialId', 'credential.id') + }) + + it('uses a fixed-size webhook ID keyset in ascending order', async () => { + await findTikTokWebhookTargetPage('act.user', 'request-3', 'webhook-100') + + expect(mockGt).toHaveBeenCalledWith('webhook.id', 'webhook-100') + expect(mockAsc).toHaveBeenCalledWith('webhook.id') + expect(mockOrderBy).toHaveBeenCalledWith({ asc: 'webhook.id' }) + expect(mockLimit).toHaveBeenCalledWith(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) + }) + + it('returns a continuation cursor when the fixed-size page is full', async () => { + queryRows.rows = Array.from({ length: TIKTOK_WEBHOOK_TARGET_PAGE_SIZE + 1 }, (_, index) => { + const webhookId = `webhook-${String(index).padStart(3, '0')}` + return { + accountId: `act.user-${ACCOUNT_UUID}`, + webhookId, + webhook: { id: webhookId }, + workflow: { id: `workflow-${index}` }, + } + }) + + const page = await findTikTokWebhookTargetPage('act.user', 'request-4') + + expect(page.targets).toHaveLength(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) + expect(page.hasMore).toBe(true) + expect(page.nextCursor).toBe('webhook-099') + }) + + it('escapes user_openid wildcard characters in the account lookup', async () => { + await findTikTokWebhookTargetPage('act_%', 'request-5') + + expect(mockLike).toHaveBeenCalledWith( + 'account.accountId', + 'act\\_\\%-________-____-____-____-____________' + ) + }) + + it('does not query for an empty user_openid', async () => { + expect(await findTikTokWebhookTargetPage('', 'request-6')).toEqual({ + hasMore: false, + nextCursor: null, + targets: [], + }) + expect(mockSelect).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/webhooks/providers/tiktok-targets.ts b/apps/sim/background/tiktok-webhook-targets.ts similarity index 63% rename from apps/sim/lib/webhooks/providers/tiktok-targets.ts rename to apps/sim/background/tiktok-webhook-targets.ts index 8fc4dc2aeaf..609bf18eb38 100644 --- a/apps/sim/lib/webhooks/providers/tiktok-targets.ts +++ b/apps/sim/background/tiktok-webhook-targets.ts @@ -8,16 +8,25 @@ import { workflowDeploymentVersion, } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq, isNull, like, or } from 'drizzle-orm' +import { and, asc, eq, gt, isNull, like, or } from 'drizzle-orm' const logger = createLogger('TikTokWebhookTargets') const ACCOUNT_ID_UUID_SUFFIX = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const ACCOUNT_ID_UUID_LIKE_SUFFIX = '________-____-____-____-____________' + +export const TIKTOK_WEBHOOK_TARGET_PAGE_SIZE = 100 export interface TikTokWebhookTarget { webhook: typeof webhook.$inferSelect workflow: typeof workflow.$inferSelect } +export interface TikTokWebhookTargetPage { + hasMore: boolean + nextCursor: string | null + targets: TikTokWebhookTarget[] +} + function escapeLikePattern(value: string): string { return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') } @@ -27,18 +36,23 @@ function openIdFromAccountId(accountId: string): string { } /** - * Resolves a TikTok user_openid to active webhook targets through the credential ID persisted in - * providerConfig. The workflow-workspace equality prevents cross-tenant event routing. + * Resolves one deterministic page of active webhook targets for the TikTok background ingress. + * The workflow-workspace equality prevents cross-tenant event routing, while the webhook ID cursor + * keeps each query and retained result set bounded without offset drift. */ -export async function findTikTokWebhookTargets( +export async function findTikTokWebhookTargetPage( userOpenId: string, - requestId: string -): Promise { - if (!userOpenId) return [] + requestId: string, + afterWebhookId?: string +): Promise { + if (!userOpenId) { + return { hasMore: false, nextCursor: null, targets: [] } + } const rows = await db .select({ accountId: account.accountId, + webhookId: webhook.id, webhook, workflow, }) @@ -78,13 +92,16 @@ export async function findTikTokWebhookTargets( .where( and( eq(account.providerId, 'tiktok'), - like(account.accountId, `${escapeLikePattern(userOpenId)}-%`), + like(account.accountId, `${escapeLikePattern(userOpenId)}-${ACCOUNT_ID_UUID_LIKE_SUFFIX}`), or( eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) + ), + afterWebhookId ? gt(webhook.id, afterWebhookId) : undefined ) ) + .orderBy(asc(webhook.id)) + .limit(TIKTOK_WEBHOOK_TARGET_PAGE_SIZE) const targets = rows .filter((row) => openIdFromAccountId(row.accountId) === userOpenId) @@ -92,11 +109,14 @@ export async function findTikTokWebhookTargets( webhook: webhookRecord, workflow: workflowRecord, })) + const nextCursor = rows.at(-1)?.webhookId ?? null + const hasMore = rows.length === TIKTOK_WEBHOOK_TARGET_PAGE_SIZE - logger.info(`[${requestId}] Resolved TikTok webhook targets`, { + logger.info(`[${requestId}] Resolved TikTok webhook target page`, { + hasMore, userOpenIdPrefix: userOpenId.slice(0, 12), - webhookCount: targets.length, + targetCount: targets.length, }) - return targets + return { hasMore, nextCursor, targets } } diff --git a/apps/sim/blocks/blocks/instagram.test.ts b/apps/sim/blocks/blocks/instagram.test.ts new file mode 100644 index 00000000000..d6f12933a03 --- /dev/null +++ b/apps/sim/blocks/blocks/instagram.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { InstagramBlock } from '@/blocks/blocks/instagram' + +describe('InstagramBlock', () => { + const buildParams = InstagramBlock.tools.config.params! + const selectTool = InstagramBlock.tools.config.tool! + + it('stays hidden from discovery until the Instagram integration is approved', () => { + expect(InstagramBlock.hideFromToolbar).toBe(true) + }) + + it('clears stale operation parameters from the runtime input merge', () => { + const inputs = { + operation: 'instagram_get_conversation_messages', + oauthCredential: 'credential-1', + conversationId: 'conversation-1', + limit: '12', + after: 'message-cursor', + mediaId: 'stale-media-id', + filename: 'stale-filename.jpg', + caption: 'stale caption', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs).toMatchObject({ + credential: 'credential-1', + conversationId: 'conversation-1', + limit: 12, + after: 'message-cursor', + }) + expect(finalInputs.mediaId).toBeUndefined() + expect(finalInputs.filename).toBeUndefined() + expect(finalInputs.caption).toBeUndefined() + }) + + it('rejects operations outside the registered Instagram tool set', () => { + expect(() => selectTool({ operation: 'instagram_unknown_operation' })).toThrow( + 'Unsupported Instagram operation' + ) + }) + + it('offers only current account insight periods and requires a demographic timeframe', () => { + const period = InstagramBlock.subBlocks.find((subBlock) => subBlock.id === 'period') + const timeframe = InstagramBlock.subBlocks.find((subBlock) => subBlock.id === 'timeframe') + + expect(period?.options?.map((option) => option.id)).toEqual(['day', 'lifetime']) + expect(timeframe?.options?.map((option) => option.id)).toEqual(['this_week', 'this_month']) + expect(timeframe?.value?.()).toBe('this_month') + expect(timeframe?.required).toEqual({ + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'lifetime' }, + }) + }) + + it('clears account insight parameters that do not apply to the selected period', () => { + const day = buildParams({ + operation: 'instagram_get_account_insights', + period: 'day', + since: '2026-07-01', + until: '2026-07-13', + timeframe: 'this_month', + }) + expect(day).toMatchObject({ period: 'day', since: '2026-07-01', until: '2026-07-13' }) + expect(day.timeframe).toBeUndefined() + + const lifetime = buildParams({ + operation: 'instagram_get_account_insights', + period: 'lifetime', + since: '2026-07-01', + until: '2026-07-13', + timeframe: 'this_month', + }) + expect(lifetime).toMatchObject({ period: 'lifetime', timeframe: 'this_month' }) + expect(lifetime.since).toBeUndefined() + expect(lifetime.until).toBeUndefined() + + expect(() => + buildParams({ operation: 'instagram_get_account_insights', period: 'week' }) + ).toThrow('Instagram account insights period must be day or lifetime') + }) +}) diff --git a/apps/sim/blocks/blocks/instagram.ts b/apps/sim/blocks/blocks/instagram.ts new file mode 100644 index 00000000000..2c346d00ce6 --- /dev/null +++ b/apps/sim/blocks/blocks/instagram.ts @@ -0,0 +1,1191 @@ +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 { + return trimmed + } + 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', +] + +const OPERATION_PARAM_KEYS: Record = { + instagram_get_profile: [], + instagram_list_media: ['igUserId', 'limit', 'after'], + instagram_get_media: ['mediaId'], + instagram_download_media: ['mediaId', 'filename'], + instagram_list_stories: ['igUserId', 'limit', 'after'], + instagram_publish_image: ['igUserId', 'caption', 'altText', 'isAiGenerated'], + instagram_publish_video: ['igUserId', 'caption'], + instagram_publish_reel: ['igUserId', 'caption', 'shareToFeed', 'thumbOffset'], + instagram_publish_story: ['igUserId'], + instagram_publish_carousel: ['igUserId', 'caption'], + instagram_get_container_status: ['containerId'], + instagram_get_publishing_limit: ['igUserId'], + instagram_list_comments: ['mediaId', 'limit', 'after'], + instagram_reply_to_comment: ['commentId', 'message'], + instagram_hide_comment: ['commentId', 'hide'], + instagram_delete_comment: ['commentId'], + instagram_set_comments_enabled: ['mediaId', 'commentEnabled'], + instagram_private_reply: ['igUserId', 'commentId', 'message'], + instagram_list_conversations: ['igUserId', 'limit', 'after'], + instagram_get_conversation_messages: ['conversationId', 'limit', 'after'], + instagram_get_message: ['messageId'], + instagram_send_text_message: ['igUserId', 'recipientId', 'message'], + instagram_get_account_insights: [ + 'igUserId', + 'metrics', + 'period', + 'since', + 'until', + 'metricType', + 'breakdown', + 'timeframe', + ], + instagram_get_media_insights: ['mediaId', 'metrics'], +} + +const INSTAGRAM_TOOL_IDS = new Set(Object.keys(OPERATION_PARAM_KEYS)) +const INSTAGRAM_OPERATION_INPUT_KEYS = new Set([ + 'image', + 'video', + 'cover', + 'media', + 'carouselMedia', + ...Object.values(OPERATION_PARAM_KEYS).flat(), +]) + +const NUMERIC_PARAM_KEYS = new Set(['limit', 'thumbOffset']) +const BOOLEAN_PARAM_KEYS = new Set(['hide', 'commentEnabled', 'shareToFeed', 'isAiGenerated']) + +export const InstagramBlock: BlockConfig = { + type: 'instagram', + name: 'Instagram', + description: 'Publish and download content, moderate comments, and manage Instagram DMs', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate Instagram into workflows. Publish and download images, videos, Reels, stories, and carousels as canonical User Files; moderate comments; send DMs; and pull account or media insights.', + docsLink: 'https://docs.sim.ai/integrations/instagram', + category: 'tools', + integrationType: IntegrationType.Marketing, + bgColor: 'radial-gradient(circle at 28% 96%, #fa8f21 9%, #d82d7e 55%, #8c3aaa 100%)', + iconColor: '#E4405F', + icon: InstagramIcon, + hideFromToolbar: true, + 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: 'Download Media', id: 'instagram_download_media' }, + { label: 'List Stories', id: 'instagram_list_stories' }, + { label: 'Publish Image', id: 'instagram_publish_image' }, + { label: 'Publish Video', id: 'instagram_publish_video' }, + { label: 'Publish Reel', id: 'instagram_publish_reel' }, + { label: 'Publish Story', id: 'instagram_publish_story' }, + { label: 'Publish Carousel', id: 'instagram_publish_carousel' }, + { label: 'Get Container Status', id: 'instagram_get_container_status' }, + { label: 'Get Publishing Limit', id: 'instagram_get_publishing_limit' }, + { label: 'List Comments', id: 'instagram_list_comments' }, + { label: 'Reply to Comment', id: 'instagram_reply_to_comment' }, + { label: 'Hide Comment', id: 'instagram_hide_comment' }, + { label: 'Delete Comment', id: 'instagram_delete_comment' }, + { label: 'Set Comments Enabled', id: 'instagram_set_comments_enabled' }, + { label: 'Private Reply', id: 'instagram_private_reply' }, + { label: 'List Conversations', id: 'instagram_list_conversations' }, + { label: 'Get Conversation Messages', id: 'instagram_get_conversation_messages' }, + { label: 'Get Message', id: 'instagram_get_message' }, + { label: 'Send Text Message', id: 'instagram_send_text_message' }, + { label: 'Get Account Insights', id: 'instagram_get_account_insights' }, + { label: 'Get Media Insights', id: 'instagram_get_media_insights' }, + ], + value: () => 'instagram_get_profile', + }, + + { + id: 'credential', + title: 'Instagram Account', + type: 'oauth-input', + serviceId: 'instagram', + canonicalParamId: 'oauthCredential', + mode: 'basic', + requiredScopes: getScopesForService('instagram'), + placeholder: 'Select Instagram account', + required: true, + }, + { + id: 'manualCredential', + title: 'Instagram Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + + { + id: 'imageUpload', + title: 'Image', + type: 'file-upload', + canonicalParamId: 'image', + placeholder: 'Upload a JPEG image to publish', + acceptedTypes: '.jpg,.jpeg,image/jpeg', + maxSize: 8, + requiresCloudStorage: true, + condition: { field: 'operation', value: 'instagram_publish_image' }, + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'imageRef', + title: 'Image', + type: 'short-input', + canonicalParamId: 'image', + placeholder: 'Reference files from previous blocks', + condition: { field: 'operation', value: 'instagram_publish_image' }, + mode: 'advanced', + required: { field: 'operation', value: 'instagram_publish_image' }, + }, + + { + id: 'videoUpload', + title: 'Video', + type: 'file-upload', + canonicalParamId: 'video', + placeholder: 'Upload a video to publish', + acceptedTypes: '.mp4,.mov,video/mp4,video/quicktime', + maxSize: 300, + requiresCloudStorage: true, + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'basic', + multiple: false, + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'videoRef', + title: 'Video', + type: 'short-input', + canonicalParamId: 'video', + placeholder: 'Reference files from previous blocks', + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'advanced', + required: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + }, + { + id: 'coverUpload', + title: 'Cover Image', + type: 'file-upload', + canonicalParamId: 'cover', + placeholder: 'Upload a JPEG cover image', + acceptedTypes: '.jpg,.jpeg,image/jpeg', + maxSize: 8, + requiresCloudStorage: true, + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'basic', + multiple: false, + required: false, + }, + { + id: 'coverRef', + title: 'Cover Image', + type: 'short-input', + canonicalParamId: 'cover', + placeholder: 'Reference files from previous blocks', + condition: { + field: 'operation', + value: ['instagram_publish_video', 'instagram_publish_reel'], + }, + mode: 'advanced', + required: false, + }, + + { + id: 'storyMediaUpload', + title: 'Media', + type: 'file-upload', + canonicalParamId: 'media', + placeholder: 'Upload a JPEG image or MP4/MOV video for the story', + acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', + maxSize: 100, + requiresCloudStorage: true, + condition: { field: 'operation', value: 'instagram_publish_story' }, + mode: 'basic', + multiple: false, + required: { field: 'operation', value: 'instagram_publish_story' }, + }, + { + id: 'storyMediaRef', + title: 'Media', + type: 'short-input', + canonicalParamId: 'media', + placeholder: 'Reference files from previous blocks', + condition: { field: 'operation', value: 'instagram_publish_story' }, + mode: 'advanced', + required: { field: 'operation', value: 'instagram_publish_story' }, + }, + + { + id: 'carouselMediaUpload', + title: 'Media', + type: 'file-upload', + canonicalParamId: 'carouselMedia', + placeholder: 'Upload 2-10 images/videos to publish', + acceptedTypes: '.jpg,.jpeg,.mp4,.mov,image/jpeg,video/mp4,video/quicktime', + maxSize: 300, + requiresCloudStorage: true, + condition: { field: 'operation', value: 'instagram_publish_carousel' }, + mode: 'basic', + multiple: true, + required: { field: 'operation', value: 'instagram_publish_carousel' }, + }, + { + id: 'carouselMediaRef', + title: 'Media', + type: 'long-input', + canonicalParamId: 'carouselMedia', + placeholder: 'Reference files from previous blocks', + condition: { field: 'operation', value: 'instagram_publish_carousel' }, + mode: 'advanced', + required: { field: 'operation', value: 'instagram_publish_carousel' }, + wandConfig: { + enabled: true, + prompt: `Generate a comma-separated list of public HTTPS media URLs for an Instagram carousel. +Use plain image URLs for photos. Prefix video URLs with "video:" (e.g. video:https://example.com/clip.mp4). +Between 2 and 10 items. Do not invent unreachable URLs — only use URLs the user provided or clearly implied. +Examples: +- "two photos" with urls A and B -> https://cdn.example/a.jpg,https://cdn.example/b.jpg +- "photo then video" -> https://cdn.example/a.jpg,video:https://cdn.example/b.mp4 + +Return ONLY the comma-separated URLs - no explanations, no extra text.`, + placeholder: 'Describe the carousel media URLs to include...', + }, + }, + + { + id: 'caption', + title: 'Caption', + type: 'long-input', + placeholder: 'Write a caption...', + condition: { + field: 'operation', + value: [ + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_carousel', + ], + }, + }, + { + id: 'altText', + title: 'Alt Text', + type: 'short-input', + placeholder: 'Accessibility description for the image', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'isAiGenerated', + title: 'AI Generated', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + value: () => 'false', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_image' }, + }, + { + id: 'shareToFeed', + title: 'Share to Feed', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + value: () => 'true', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_reel' }, + }, + { + id: 'thumbOffset', + title: 'Thumbnail Offset (ms)', + type: 'short-input', + placeholder: 'Video frame offset in milliseconds for the cover thumbnail', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_publish_reel' }, + }, + + { + id: 'mediaId', + title: 'Media ID', + type: 'short-input', + placeholder: 'Enter Instagram media ID', + condition: { + field: 'operation', + value: [ + 'instagram_get_media', + 'instagram_download_media', + 'instagram_list_comments', + 'instagram_set_comments_enabled', + 'instagram_get_media_insights', + ], + }, + required: { + field: 'operation', + value: [ + 'instagram_get_media', + 'instagram_download_media', + 'instagram_list_comments', + 'instagram_set_comments_enabled', + 'instagram_get_media_insights', + ], + }, + }, + { + id: 'filename', + title: 'Filename', + type: 'short-input', + placeholder: 'Optional filename override', + mode: 'advanced', + condition: { field: 'operation', value: 'instagram_download_media' }, + }, + { + id: 'containerId', + title: 'Container ID', + 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: 'Lifetime', id: 'lifetime' }, + ], + 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', + and: { field: 'period', value: 'day' }, + }, + 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', + and: { field: 'period', value: 'day' }, + }, + 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: [ + { label: 'This Week', id: 'this_week' }, + { label: 'This Month', id: 'this_month' }, + ], + value: () => 'this_month', + mode: 'advanced', + condition: { + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'lifetime' }, + }, + required: { + field: 'operation', + value: 'instagram_get_account_insights', + and: { field: 'period', value: 'lifetime' }, + }, + }, + + { + 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_stories', + 'instagram_list_comments', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + ], + }, + }, + { + 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_stories', + 'instagram_list_comments', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + ], + }, + }, + { + 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_download_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) => { + const operation = + typeof params.operation === 'string' ? params.operation : 'instagram_get_profile' + if (!INSTAGRAM_TOOL_IDS.has(operation)) { + throw new Error(`Unsupported Instagram operation: ${operation}`) + } + return operation + }, + params: (params) => { + const operation = typeof params.operation === 'string' ? params.operation : '' + const result: Record = { + credential: params.oauthCredential, + } + + for (const key of INSTAGRAM_OPERATION_INPUT_KEYS) { + result[key] = undefined + } + + if (operation === 'instagram_publish_image') { + const resolved = resolveSingleMediaInput(params.image) + if (resolved) result.image = resolved + } else if ( + operation === 'instagram_publish_video' || + operation === 'instagram_publish_reel' + ) { + const resolvedVideo = resolveSingleMediaInput(params.video) + if (resolvedVideo) result.video = resolvedVideo + const resolvedCover = resolveSingleMediaInput(params.cover) + if (resolvedCover) result.cover = resolvedCover + } else if (operation === 'instagram_publish_story') { + const resolved = resolveSingleMediaInput(params.media) + if (resolved) result.media = resolved + } else if (operation === 'instagram_publish_carousel') { + const resolved = resolveCarouselMediaInput(params.carouselMedia) + if (resolved) result.media = resolved + } + + for (const key of OPERATION_PARAM_KEYS[operation] ?? []) { + const value = params[key] + if (value === undefined || value === null || value === '') continue + + if (NUMERIC_PARAM_KEYS.has(key)) { + result[key] = Number(value) + } else if (BOOLEAN_PARAM_KEYS.has(key)) { + result[key] = value === true || value === 'true' + } else { + result[key] = value + } + } + + if (operation === 'instagram_get_account_insights') { + if (result.period === 'day') { + result.timeframe = undefined + } else if (result.period === 'lifetime') { + result.since = undefined + result.until = undefined + } else { + throw new Error('Instagram account insights period must be day or lifetime') + } + } + + return result + }, + }, + }, + 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' }, + filename: { type: 'string', description: 'Optional filename override for Download Media' }, + containerId: { type: 'string', description: 'Media container ID' }, + commentId: { type: 'string', description: 'Comment ID' }, + message: { type: 'string', description: 'Message or reply text' }, + 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: 'Account insight period: day or lifetime' }, + since: { type: 'string', description: 'Account insights range start' }, + until: { type: 'string', description: 'Account insights range end' }, + metricType: { type: 'string', description: 'Account insights metric_type' }, + breakdown: { type: 'string', description: 'Account insights breakdown dimension' }, + timeframe: { + type: 'string', + description: 'Demographic insight timeframe: this_week or this_month', + }, + limit: { type: 'number', description: 'Maximum number of results' }, + after: { type: 'string', description: 'Pagination cursor' }, + igUserId: { type: 'string', description: 'Instagram professional account user ID' }, + }, + outputs: { + userId: { + type: 'string', + description: 'Instagram professional account user_id', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + id: { + type: 'string', + description: 'Graph object, comment, or message ID', + condition: { + field: 'operation', + value: [ + 'instagram_get_profile', + 'instagram_get_media', + 'instagram_reply_to_comment', + 'instagram_get_message', + ], + }, + }, + username: { + type: 'string', + description: 'Instagram username', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + name: { + type: 'string', + description: 'Display name', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + accountType: { + type: 'string', + description: 'Business or Media_Creator', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + profilePictureUrl: { + type: 'string', + description: 'Profile picture URL', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + followersCount: { + type: 'number', + description: 'Follower count', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + followsCount: { + type: 'number', + description: 'Following count', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + mediaCount: { + type: 'number', + description: 'Media count', + condition: { field: 'operation', value: 'instagram_get_profile' }, + }, + media: { + type: 'array', + description: 'Media objects from this page', + condition: { field: 'operation', value: 'instagram_list_media' }, + }, + files: { + type: 'file[]', + description: + 'Downloaded media as canonical User Files (100 MB max each), ordered by carousel position', + condition: { field: 'operation', value: 'instagram_download_media' }, + }, + downloadedCount: { + type: 'number', + description: 'Number of media files downloaded', + condition: { field: 'operation', value: 'instagram_download_media' }, + }, + caption: { + type: 'string', + description: 'Media caption text', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + mediaType: { + type: 'string', + description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM', + condition: { + field: 'operation', + value: ['instagram_get_media', 'instagram_download_media'], + }, + }, + mediaProductType: { + type: 'string', + description: 'Feed, Reels, or Stories product type', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + mediaUrl: { + type: 'string', + description: 'Instagram media URL when available', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + permalink: { + type: 'string', + description: 'Permalink to the post', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + timestamp: { + type: 'string', + description: 'ISO timestamp', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + likeCount: { + type: 'number', + description: 'Like count', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + commentsCount: { + type: 'number', + description: 'Comments count', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + children: { + type: 'array', + description: 'Carousel child media IDs', + condition: { field: 'operation', value: 'instagram_get_media' }, + }, + stories: { + type: 'array', + description: 'Active stories from this page', + condition: { field: 'operation', value: 'instagram_list_stories' }, + }, + containerId: { + type: 'string', + description: 'Media container ID', + condition: { + field: 'operation', + value: [ + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_container_status', + ], + }, + }, + mediaId: { + type: 'string', + description: 'Published or downloaded media ID', + condition: { + field: 'operation', + value: [ + 'instagram_download_media', + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + ], + }, + }, + statusCode: { + type: 'string', + description: 'Container status (EXPIRED, ERROR, FINISHED, IN_PROGRESS, or PUBLISHED)', + condition: { + field: 'operation', + value: [ + 'instagram_publish_image', + 'instagram_publish_video', + 'instagram_publish_reel', + 'instagram_publish_story', + 'instagram_publish_carousel', + 'instagram_get_container_status', + ], + }, + }, + status: { + type: 'string', + description: 'Detailed container status message', + condition: { field: 'operation', value: 'instagram_get_container_status' }, + }, + quotaUsage: { + type: 'number', + description: 'Publishes used in the current window', + condition: { field: 'operation', value: 'instagram_get_publishing_limit' }, + }, + config: { + type: 'json', + description: 'Publishing quota config', + condition: { field: 'operation', value: 'instagram_get_publishing_limit' }, + }, + comments: { + type: 'array', + description: 'Comments from this page', + condition: { field: 'operation', value: 'instagram_list_comments' }, + }, + success: { + type: 'boolean', + description: 'Whether the operation succeeded', + condition: { + field: 'operation', + value: [ + 'instagram_hide_comment', + 'instagram_delete_comment', + 'instagram_set_comments_enabled', + ], + }, + }, + conversations: { + type: 'array', + description: 'Instagram Direct conversations from this page', + condition: { field: 'operation', value: 'instagram_list_conversations' }, + }, + conversationId: { + type: 'string', + description: 'Conversation ID', + condition: { field: 'operation', value: 'instagram_get_conversation_messages' }, + }, + messages: { + type: 'array', + description: 'Message references; use Get Message for sender, recipient, and text', + condition: { field: 'operation', value: 'instagram_get_conversation_messages' }, + }, + createdTime: { + type: 'string', + description: 'Message created timestamp', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + fromId: { + type: 'string', + description: 'Sender Instagram-scoped ID', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + fromUsername: { + type: 'string', + description: 'Sender username', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + toId: { + type: 'string', + description: 'Recipient ID', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + message: { + type: 'string', + description: 'Message text', + condition: { field: 'operation', value: 'instagram_get_message' }, + }, + messageId: { + type: 'string', + description: 'Sent message ID', + condition: { + field: 'operation', + value: ['instagram_private_reply', 'instagram_send_text_message'], + }, + }, + recipientId: { + type: 'string', + description: 'DM recipient ID', + condition: { + field: 'operation', + value: ['instagram_private_reply', 'instagram_send_text_message'], + }, + }, + insights: { + type: 'array', + description: 'Insight metrics', + condition: { + field: 'operation', + value: ['instagram_get_account_insights', 'instagram_get_media_insights'], + }, + }, + nextCursor: { + type: 'string', + description: 'Pagination cursor for the next page', + condition: { + field: 'operation', + value: [ + 'instagram_list_media', + 'instagram_list_stories', + 'instagram_list_comments', + 'instagram_list_conversations', + 'instagram_get_conversation_messages', + ], + }, + }, + }, +} + +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, and final container status.', + }, + { + 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 message references.\n2. Optionally Get Message for one of the 20 most recent message IDs when you need full details.\n3. Send Text Message to the recipient ID with a clear, helpful reply.\n\n## Output\nThe sent message ID and recipient ID.', + }, + { + name: 'fetch-instagram-insights', + 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.', + }, + { + name: 'download-instagram-media', + description: 'Download an Instagram post or story as canonical User Files.', + content: + '# Download Instagram Media\n\nMaterialize Instagram media into durable User Files for downstream blocks.\n\n## Steps\n1. Use List Media, Get Media, or List Stories to find the media ID.\n2. Run Download Media with that ID. Carousel children are downloaded in display order.\n3. Pass the files output directly to a file[] input such as Gmail attachments.\n\n## Output\nA canonical User File array plus the source media ID, media type, and downloaded count.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/blocks/tiktok.test.ts b/apps/sim/blocks/blocks/tiktok.test.ts new file mode 100644 index 00000000000..a9aec3ca9eb --- /dev/null +++ b/apps/sim/blocks/blocks/tiktok.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { TikTokBlock } from '@/blocks/blocks/tiktok' + +describe('TikTokBlock', () => { + const buildParams = TikTokBlock.tools.config.params! + const selectTool = TikTokBlock.tools.config.tool! + + it('keeps direct posting unavailable until one-use human approval is supported', () => { + expect(TikTokBlock.tools.access).not.toContain('tiktok_direct_post_video') + expect(() => selectTool({ operation: 'tiktok_direct_post_video' })).toThrow( + 'Unsupported TikTok operation' + ) + }) + + it('uses one canonical file parameter for upload and reference modes', () => { + const fileInputs = TikTokBlock.subBlocks.filter( + (subBlock) => subBlock.id === 'videoFile' || subBlock.id === 'videoFileRef' + ) + + expect(fileInputs).toHaveLength(2) + expect(fileInputs.every((subBlock) => subBlock.canonicalParamId === 'file')).toBe(true) + }) + + it('forwards only draft parameters and preserves a canonical UserFile', () => { + const file = { + id: 'file-1', + key: 'workspace/workspace-1/file-1', + name: 'video.mp4', + size: 1024, + type: 'video/mp4', + url: '/api/files/serve?key=workspace%2Fworkspace-1%2Ffile-1', + } + + const inputs = { + operation: 'tiktok_upload_video_draft', + file, + privacyLevel: 'PUBLIC_TO_EVERYONE', + musicUsageConsent: 'accepted', + videoIds: 'stale-video-id', + } + const finalInputs = { ...inputs, ...buildParams(inputs) } + + expect(finalInputs.file).toEqual(file) + expect(finalInputs.privacyLevel).toBeUndefined() + expect(finalInputs.musicUsageConsent).toBeUndefined() + expect(finalInputs.videoIds).toBeUndefined() + }) + + it('declares file-like outputs with canonical block output types', () => { + expect(TikTokBlock.outputs.avatarFile.type).toBe('file') + expect(TikTokBlock.outputs.creatorAvatarFile.type).toBe('file') + expect(TikTokBlock.outputs.videos.type).toBe('array') + expect(TikTokBlock.outputs.publiclyAvailablePostId.type).toBe('array') + }) +}) diff --git a/apps/sim/blocks/blocks/tiktok.ts b/apps/sim/blocks/blocks/tiktok.ts index dcd2dabcb69..0cddbde45d6 100644 --- a/apps/sim/blocks/blocks/tiktok.ts +++ b/apps/sim/blocks/blocks/tiktok.ts @@ -6,15 +6,41 @@ import { normalizeFileInput } from '@/blocks/utils' import type { TikTokResponse } from '@/tools/tiktok/types' import { getTrigger } from '@/triggers' -const VIDEO_POST_OPERATIONS = ['tiktok_direct_post_video', 'tiktok_upload_video_draft'] +const VIDEO_UPLOAD_OPERATIONS = ['tiktok_upload_video_draft'] +const TIKTOK_TOOL_IDS = new Set([ + 'tiktok_get_user', + 'tiktok_list_videos', + 'tiktok_query_videos', + 'tiktok_query_creator_info', + 'tiktok_upload_video_draft', + 'tiktok_get_post_status', +]) +const TIKTOK_OPERATION_INPUT_KEYS = [ + 'fields', + 'maxCount', + 'cursor', + 'videoIds', + 'file', + 'publishId', + 'title', + 'privacyLevel', + 'disableDuet', + 'disableStitch', + 'disableComment', + 'videoCoverTimestampMs', + 'isAigc', + 'brandContentToggle', + 'brandOrganicToggle', + 'musicUsageConsent', +] as const export const TikTokBlock: BlockConfig = { type: 'tiktok', name: 'TikTok', - description: 'Access TikTok profiles and videos, and publish content', + description: 'Access TikTok profiles and videos, and upload inbox drafts', authMode: AuthMode.OAuth, longDescription: - 'Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos directly to TikTok (or send them to the inbox as drafts) from an uploaded file or a file produced earlier in the workflow, then track post status.', + 'Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Send uploaded videos to the TikTok inbox as drafts for human review and publishing, then track post status.', docsLink: 'https://docs.sim.ai/integrations/tiktok', category: 'tools', integrationType: IntegrationType.Communication, @@ -33,14 +59,12 @@ export const TikTokBlock: BlockConfig = { { label: 'List Videos', id: 'tiktok_list_videos' }, { label: 'Query Videos', id: 'tiktok_query_videos' }, { label: 'Query Creator Info', id: 'tiktok_query_creator_info' }, - { label: 'Direct Post Video', id: 'tiktok_direct_post_video' }, { label: 'Upload Video Draft', id: 'tiktok_upload_video_draft' }, { label: 'Get Post Status', id: 'tiktok_get_post_status' }, ], value: () => 'tiktok_get_user', }, - // --- OAuth Credential --- { id: 'credential', title: 'TikTok Account', @@ -62,7 +86,6 @@ export const TikTokBlock: BlockConfig = { required: true, }, - // --- Get User Info --- { id: 'fields', title: 'Fields', @@ -79,7 +102,6 @@ export const TikTokBlock: BlockConfig = { }, }, - // --- List Videos --- { id: 'maxCount', title: 'Max Count', @@ -98,7 +120,6 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_list_videos' }, }, - // --- Query Videos --- { id: 'videoIds', title: 'Video IDs', @@ -108,7 +129,6 @@ export const TikTokBlock: BlockConfig = { required: { field: 'operation', value: 'tiktok_query_videos' }, }, - // --- Video file (Direct Post Video / Upload Video Draft) — Gmail-style upload ⇄ block ref --- { id: 'videoFile', title: 'Video File', @@ -120,8 +140,8 @@ export const TikTokBlock: BlockConfig = { multiple: false, maxSize: 250, description: 'MP4, MOV, or WebM video up to 250 MB.', - condition: { field: 'operation', value: VIDEO_POST_OPERATIONS }, - required: { field: 'operation', value: VIDEO_POST_OPERATIONS }, + condition: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, + required: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, }, { id: 'videoFileRef', @@ -130,134 +150,10 @@ export const TikTokBlock: BlockConfig = { canonicalParamId: 'file', mode: 'advanced', placeholder: 'Reference a video from a previous block', - condition: { field: 'operation', value: VIDEO_POST_OPERATIONS }, - required: { field: 'operation', value: VIDEO_POST_OPERATIONS }, + condition: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, + required: { field: 'operation', value: VIDEO_UPLOAD_OPERATIONS }, }, - // --- Caption (Direct Post Video) --- - { - id: 'title', - title: 'Title / Caption', - type: 'long-input', - placeholder: 'Caption with #hashtags and @mentions', - description: 'Video caption. Maximum 2200 characters.', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - - // --- Privacy & interaction settings (Direct Post Video) --- - { - id: 'privacyLevel', - title: 'Privacy Level', - type: 'dropdown', - options: [ - { label: 'Public', id: 'PUBLIC_TO_EVERYONE' }, - { label: 'Friends', id: 'MUTUAL_FOLLOW_FRIENDS' }, - { label: 'Followers', id: 'FOLLOWER_OF_CREATOR' }, - { label: 'Only Me', id: 'SELF_ONLY' }, - ], - description: - 'Choose manually from the privacyLevelOptions returned by Query Creator Info. TikTok prohibits preselecting a privacy level. Unaudited apps are restricted to Only Me.', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'disableComment', - title: 'Disable Comments', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'disableDuet', - title: 'Disable Duet', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'disableStitch', - title: 'Disable Stitch', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'videoCoverTimestampMs', - title: 'Cover Timestamp (ms)', - type: 'short-input', - placeholder: '1000', - description: 'Timestamp in milliseconds to use as the video cover image.', - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'isAigc', - title: 'AI-Generated Content', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - value: () => 'false', - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'brandContentToggle', - title: 'Paid Partnership', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - value: () => 'false', - description: - 'Disclose this post as a paid partnership promoting a third-party business. Branded content cannot be posted with Only Me privacy.', - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'brandOrganicToggle', - title: 'Promotes Own Business', - type: 'dropdown', - options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, - ], - value: () => 'false', - description: "Disclose this post as promoting the creator's own business.", - mode: 'advanced', - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - { - id: 'musicUsageConsent', - title: 'TikTok Music Usage Confirmation', - type: 'dropdown', - options: [ - { - label: "I agree to TikTok's Music Usage Confirmation", - id: 'accepted', - }, - ], - description: - "By posting, you agree to TikTok's Music Usage Confirmation. TikTok requires explicit consent before content is uploaded.", - condition: { field: 'operation', value: 'tiktok_direct_post_video' }, - required: { field: 'operation', value: 'tiktok_direct_post_video' }, - }, - - // --- Get Post Status --- { id: 'publishId', title: 'Publish ID', @@ -297,69 +193,52 @@ export const TikTokBlock: BlockConfig = { 'tiktok_list_videos', 'tiktok_query_videos', 'tiktok_query_creator_info', - 'tiktok_direct_post_video', 'tiktok_upload_video_draft', 'tiktok_get_post_status', ], config: { - tool: (params) => params.operation || 'tiktok_get_user', + tool: (params) => { + const operation = params.operation || 'tiktok_get_user' + if (!TIKTOK_TOOL_IDS.has(operation)) { + throw new Error(`Unsupported TikTok operation: ${operation}`) + } + return operation + }, params: (params) => { const operation = params.operation || 'tiktok_get_user' - const toBoolean = (value: unknown): boolean | undefined => - value === undefined || value === '' ? undefined : String(value).toLowerCase() === 'true' + const result: Record = {} + for (const key of TIKTOK_OPERATION_INPUT_KEYS) { + result[key] = undefined + } switch (operation) { case 'tiktok_get_user': - return { - ...(params.fields && { fields: params.fields }), - } + if (params.fields) result.fields = params.fields + break case 'tiktok_list_videos': - return { - ...(params.maxCount && { maxCount: Number(params.maxCount) }), - ...(params.cursor !== undefined && - params.cursor !== '' && { cursor: Number(params.cursor) }), + if (params.maxCount) result.maxCount = Number(params.maxCount) + if (params.cursor !== undefined && params.cursor !== '') { + result.cursor = Number(params.cursor) } + break case 'tiktok_query_videos': - return { - videoIds: (params.videoIds || '') - .split(/[,\n]+/) - .map((id: string) => id.trim()) - .filter(Boolean), - } + result.videoIds = (params.videoIds || '') + .split(/[,\n]+/) + .map((id: string) => id.trim()) + .filter(Boolean) + break case 'tiktok_query_creator_info': - return {} - case 'tiktok_direct_post_video': { - const file = normalizeFileInput(params.file, { single: true }) - return { - file, - title: params.title, - privacyLevel: params.privacyLevel, - disableDuet: toBoolean(params.disableDuet), - disableStitch: toBoolean(params.disableStitch), - disableComment: toBoolean(params.disableComment), - ...(params.videoCoverTimestampMs !== undefined && - params.videoCoverTimestampMs !== '' && { - videoCoverTimestampMs: Number(params.videoCoverTimestampMs), - }), - isAigc: toBoolean(params.isAigc), - brandContentToggle: toBoolean(params.brandContentToggle), - brandOrganicToggle: toBoolean(params.brandOrganicToggle), - musicUsageConsent: params.musicUsageConsent, - } - } + break case 'tiktok_upload_video_draft': { - const file = normalizeFileInput(params.file, { single: true }) - return { - file, - } + result.file = normalizeFileInput(params.file, { single: true }) + break } case 'tiktok_get_post_status': - return { - publishId: params.publishId, - } - default: - return {} + result.publishId = params.publishId + break } + + return result }, }, }, @@ -378,30 +257,10 @@ export const TikTokBlock: BlockConfig = { type: 'json', description: 'Video file to upload (uploaded file or reference from a previous block)', }, - title: { type: 'string', description: 'Video caption or title' }, - privacyLevel: { type: 'string', description: 'Privacy level for the post' }, - disableComment: { type: 'string', description: 'Whether to disable comments' }, - disableDuet: { type: 'string', description: 'Whether to disable duet' }, - disableStitch: { type: 'string', description: 'Whether to disable stitch' }, - videoCoverTimestampMs: { type: 'number', description: 'Video cover timestamp in ms' }, - isAigc: { type: 'string', description: 'Whether the video is AI-generated content' }, - brandContentToggle: { - type: 'string', - description: 'Whether the post is a paid partnership promoting a third-party business', - }, - brandOrganicToggle: { - type: 'string', - description: "Whether the post promotes the creator's own business", - }, - musicUsageConsent: { - type: 'string', - description: "Explicit acceptance of TikTok's Music Usage Confirmation", - }, publishId: { type: 'string', description: 'Publish ID to check status for' }, }, outputs: { - // Get User Info openId: { type: 'string', description: 'Unique TikTok user ID for this application', @@ -464,11 +323,10 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_get_user' }, }, - // List/Query Videos videos: { - type: 'json', + type: 'array', description: - 'Array of video objects (id, title, coverImageUrl, embedLink, embedHtml, duration, createTime, shareUrl, videoDescription, width, height, viewCount, likeCount, commentCount, shareCount)', + 'Video objects with metadata and provider cover URLs. Cover URLs expire after six hours and are not original-video downloads.', condition: { field: 'operation', value: ['tiktok_list_videos', 'tiktok_query_videos'] }, }, cursor: { @@ -482,10 +340,15 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_list_videos' }, }, - // Query Creator Info creatorAvatarUrl: { type: 'string', - description: 'URL of the creator avatar', + description: 'Provider URL of the creator avatar; expires after two hours', + condition: { field: 'operation', value: 'tiktok_query_creator_info' }, + }, + creatorAvatarFile: { + type: 'file', + description: + 'Canonical workflow file containing the creator avatar, suitable for downstream attachments', condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, creatorUsername: { @@ -499,7 +362,7 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, privacyLevelOptions: { - type: 'json', + type: 'array', description: 'Available privacy levels for posting (array of strings)', condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, @@ -524,17 +387,15 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_query_creator_info' }, }, - // Direct Post / Upload Draft publishId: { type: 'string', description: 'Publish ID for tracking post status', condition: { field: 'operation', - value: VIDEO_POST_OPERATIONS, + value: VIDEO_UPLOAD_OPERATIONS, }, }, - // Get Post Status status: { type: 'string', description: @@ -547,7 +408,7 @@ export const TikTokBlock: BlockConfig = { condition: { field: 'operation', value: 'tiktok_get_post_status' }, }, publiclyAvailablePostId: { - type: 'json', + type: 'array', description: 'Array of public post IDs once the content is published', condition: { field: 'operation', value: 'tiktok_get_post_status' }, }, @@ -568,15 +429,6 @@ export const TikTokBlockMeta = { tags: ['marketing', 'content-management'], url: 'https://www.tiktok.com', templates: [ - { - icon: TikTokIcon, - title: 'User-reviewed TikTok publisher', - prompt: - 'Build a workflow that prepares a generated video and editable AI-written caption, asks the user to review the video, choose TikTok privacy and interaction settings, and explicitly accept the Music Usage Confirmation before publishing, then checks post status until it completes.', - modules: ['agent', 'workflows'], - category: 'marketing', - tags: ['marketing', 'automation'], - }, { icon: TikTokIcon, title: 'TikTok content calendar drafts', @@ -608,13 +460,6 @@ export const TikTokBlockMeta = { }, ], skills: [ - { - name: 'publish-video-to-tiktok', - description: - 'Guide a user-reviewed direct post to TikTok from an uploaded file or a previous block.', - content: - '# Publish a Video to TikTok\n\nGuide a user-reviewed post to a connected TikTok account.\n\n## Steps\n1. Run Query Creator Info immediately before posting to confirm the account, posting permissions, allowed privacy levels, interaction restrictions, and maximum duration.\n2. Show the user the video and an editable Title/Caption; never publish an unattended or unreviewed file.\n3. Have the user manually choose Privacy Level and each interaction setting from the currently allowed options.\n4. Require the user to accept the TikTok Music Usage Confirmation, then use Direct Post Video with the reviewed settings.\n5. Use Get Post Status with the returned Publish ID until the post completes or fails.\n\n## Output\nReturn the Publish ID and final status (PUBLISH_COMPLETE or FAILED with a reason).', - }, { name: 'send-video-draft-to-inbox', description: "Send a video to the user's TikTok inbox for manual review before posting.", @@ -623,9 +468,9 @@ export const TikTokBlockMeta = { }, { name: 'check-tiktok-post-status', - description: 'Poll the status of a TikTok post or draft until it completes or fails.', + description: 'Poll the status of a TikTok inbox draft until it completes or fails.', content: - '# Check TikTok Post Status\n\nTrack the outcome of a post or draft submitted with any TikTok publish operation.\n\n## Steps\n1. Capture the Publish ID returned by Direct Post Video or Upload Video Draft.\n2. Call Get Post Status with that Publish ID.\n3. Branch on the returned status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD means still in progress, SEND_TO_USER_INBOX means a draft is waiting on the user, PUBLISH_COMPLETE means it succeeded, and FAILED means it did not (read failReason for why).\n4. Repeat on a delay for in-progress statuses until a terminal state is reached.\n\n## Output\nReturn the final status, failReason (if any), and the publiclyAvailablePostId once published.', + '# Check TikTok Draft Status\n\nTrack the outcome of a video submitted to the TikTok inbox for human review.\n\n## Steps\n1. Capture the Publish ID returned by Upload Video Draft.\n2. Call Get Post Status with that Publish ID.\n3. Branch on the returned status: PROCESSING_UPLOAD/PROCESSING_DOWNLOAD means still in progress, SEND_TO_USER_INBOX means the draft is waiting on the user, PUBLISH_COMPLETE means the user published it, and FAILED means it did not complete (read failReason for why).\n4. Repeat on a delay for in-progress statuses until a terminal state is reached.\n\n## Output\nReturn the final status, failReason (if any), and the publiclyAvailablePostId once published.', }, { name: 'summarize-tiktok-video-performance', diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index 333b33ba5ff..ff68d98df47 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -145,6 +145,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, @@ -477,6 +478,7 @@ export const BLOCK_REGISTRY: Record = { incidentio: IncidentioBlock, infisical: InfisicalBlock, input_trigger: InputTriggerBlock, + instagram: InstagramBlock, instantly: InstantlyBlock, intercom: IntercomBlock, intercom_v2: IntercomV2Block, @@ -774,6 +776,7 @@ export const BLOCK_META_REGISTRY: Record = { imap: ImapBlockMeta, incidentio: IncidentioBlockMeta, infisical: InfisicalBlockMeta, + instagram: InstagramBlockMeta, instantly: InstantlyBlockMeta, intercom: IntercomBlockMeta, intercom_v2: IntercomV2BlockMeta, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index d063f43d38a..9b6653c3283 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -381,6 +381,11 @@ export interface SubBlockConfig { acceptedTypes?: string multiple?: boolean maxSize?: number + /** + * When true, FileUpload checks for S3/Blob and warns / disables new uploads if missing. + * Used by providers (e.g. Instagram) that need a Meta-fetchable public HTTPS URL. + */ + requiresCloudStorage?: boolean // Slider-specific properties step?: number integer?: boolean diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 26ce27e35ad..e7acb6322a2 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1467,6 +1467,21 @@ export function LinkedInIcon(props: SVGProps) { ) } +/** Instagram camera glyph rendered with the block's brand gradient background. */ +export function InstagramIcon(props: SVGProps) { + return ( + + ) +} + export function CrunchbaseIcon(props: SVGProps) { return ( ({ + mockDownloadFileFromUrl: vi.fn(), + mockUploadExecutionFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadFileFromUrl: mockDownloadFileFromUrl, +})) + +vi.mock('@/lib/uploads/contexts/execution', () => ({ + uploadExecutionFile: mockUploadExecutionFile, + uploadFileFromRawData: vi.fn(), +})) + +import { FileToolProcessor } from '@/executor/utils/file-tool-processor' + +const executionContext = { + executionId: 'execution-1', + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', +} as ExecutionContext + +const toolConfig = { + id: 'test_file_output', + name: 'Test File Output', + description: 'Test file output', + version: '1.0.0', + params: {}, + request: { + url: () => 'https://example.com', + method: 'GET', + }, + outputs: { + file: { type: 'file' }, + }, +} satisfies ToolConfig + +describe('FileToolProcessor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUploadExecutionFile.mockResolvedValue({ + id: 'file-1', + key: 'workspace/workspace-1/file-1', + name: 'avatar.png', + size: 12, + type: 'image/png', + url: '/api/files/serve?key=workspace%2Fworkspace-1%2Ffile-1', + } satisfies UserFile) + }) + + it('caps URL downloads and stores raster images using byte-derived metadata', async () => { + const png = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(4), + ]) + mockDownloadFileFromUrl.mockResolvedValue(png) + + await FileToolProcessor.processToolOutputs( + { + file: { + name: 'avatar.jpg', + mimeType: 'image/jpeg', + url: 'https://example.com/avatar', + }, + }, + toolConfig, + executionContext + ) + + expect(mockDownloadFileFromUrl).toHaveBeenCalledWith('https://example.com/avatar', { + maxBytes: MAX_FILE_SIZE, + userId: 'user-1', + }) + expect(mockUploadExecutionFile).toHaveBeenCalledWith( + expect.objectContaining({ executionId: 'execution-1' }), + png, + 'avatar.png', + 'image/png', + 'user-1' + ) + }) + + it('rejects oversized in-memory tool files before upload', async () => { + const oversizedBuffer = Buffer.alloc(1) + Object.defineProperty(oversizedBuffer, 'length', { value: MAX_FILE_SIZE + 1 }) + + await expect( + FileToolProcessor.processToolOutputs( + { + file: { + data: oversizedBuffer, + name: 'oversized.bin', + mimeType: 'application/octet-stream', + }, + }, + toolConfig, + executionContext + ) + ).rejects.toThrow('exceeds the maximum allowed size') + + expect(mockUploadExecutionFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/utils/file-tool-processor.ts b/apps/sim/executor/utils/file-tool-processor.ts index eabac4b35c8..72bdaa3bad3 100644 --- a/apps/sim/executor/utils/file-tool-processor.ts +++ b/apps/sim/executor/utils/file-tool-processor.ts @@ -3,11 +3,49 @@ import { toError } from '@sim/utils/errors' import { isUserFile } from '@/lib/core/utils/user-file' import { uploadExecutionFile, uploadFileFromRawData } from '@/lib/uploads/contexts/execution' import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server' +import { MAX_FILE_SIZE, sniffImageContentType } from '@/lib/uploads/utils/validation' import type { ExecutionContext, UserFile } from '@/executor/types' import type { ToolConfig, ToolFileData } from '@/tools/types' const logger = createLogger('FileToolProcessor') +const IMAGE_FILE_EXTENSIONS: Record = { + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +} + +function assertFileSize(size: number, fileName: string): void { + if (size > MAX_FILE_SIZE) { + throw new Error(`File '${fileName}' exceeds the maximum allowed size of ${MAX_FILE_SIZE} bytes`) + } +} + +function resolveStoredFileMetadata( + fileName: string, + declaredMimeType: string, + buffer: Buffer +): { fileName: string; mimeType: string } { + if (!declaredMimeType.startsWith('image/')) { + return { fileName, mimeType: declaredMimeType } + } + + const mimeType = sniffImageContentType(buffer) + if (!mimeType) { + return { + fileName: `${fileName.replace(/\.[^.]+$/, '')}.bin`, + mimeType: 'application/octet-stream', + } + } + + const extension = IMAGE_FILE_EXTENSIONS[mimeType] + return { + fileName: extension ? `${fileName.replace(/\.[^.]+$/, '')}.${extension}` : fileName, + mimeType, + } +} + /** * Processes tool outputs and converts file-typed outputs to UserFile objects. * This enables tools to return file data that gets automatically stored in the @@ -90,9 +128,11 @@ export class FileToolProcessor { throw new Error(`Output '${outputKey}' is marked as file[] but is not an array`) } - return Promise.all( - fileData.map((file, index) => FileToolProcessor.processFileData(file, executionContext)) - ) + const files: UserFile[] = [] + for (const file of fileData) { + files.push(await FileToolProcessor.processFileData(file, executionContext)) + } + return files } /** @@ -114,6 +154,7 @@ export class FileToolProcessor { let buffer: Buffer | null = null if (Buffer.isBuffer(data.data)) { + assertFileSize(data.data.length, data.name) buffer = data.data } else if ( data.data && @@ -123,6 +164,7 @@ export class FileToolProcessor { ) { const serializedBuffer = data.data as { type: string; data: number[] } if (serializedBuffer.type === 'Buffer' && Array.isArray(serializedBuffer.data)) { + assertFileSize(serializedBuffer.data.length, data.name) buffer = Buffer.from(serializedBuffer.data) } else { throw new Error(`Invalid serialized buffer format for ${data.name}`) @@ -134,17 +176,24 @@ export class FileToolProcessor { base64Data = base64Data.replace(/-/g, '+').replace(/_/g, '/') } + const paddingBytes = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0 + assertFileSize(Math.floor((base64Data.length * 3) / 4) - paddingBytes, data.name) buffer = Buffer.from(base64Data, 'base64') } if (!buffer && data.url) { - buffer = await downloadFileFromUrl(data.url, { userId: context.userId }) + buffer = await downloadFileFromUrl(data.url, { + maxBytes: MAX_FILE_SIZE, + userId: context.userId, + }) } if (buffer) { if (buffer.length === 0) { throw new Error(`File '${data.name}' has zero bytes`) } + assertFileSize(buffer.length, data.name) + const storedMetadata = resolveStoredFileMetadata(data.name, data.mimeType, buffer) return await uploadExecutionFile( { @@ -153,8 +202,8 @@ export class FileToolProcessor { executionId: context.executionId || '', }, buffer, - data.name, - data.mimeType, + storedMetadata.fileName, + storedMetadata.mimeType, context.userId ) } diff --git a/apps/sim/hooks/queries/oauth/oauth-connections.ts b/apps/sim/hooks/queries/oauth/oauth-connections.ts index 0319ee21689..7ec177502f1 100644 --- a/apps/sim/hooks/queries/oauth/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth/oauth-connections.ts @@ -143,6 +143,12 @@ export function useConnectOAuthService() { return { success: true } } + if (providerId === 'instagram') { + const returnUrl = encodeURIComponent(callbackURL) + window.location.href = `/api/auth/instagram/authorize?returnUrl=${returnUrl}` + return { success: true } + } + if (providerId === 'shopify') { const returnUrl = encodeURIComponent(callbackURL) window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}` diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index b58da2b15ab..bced075225d 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -6,6 +6,7 @@ import { backoffWithJitter } from '@sim/utils/retry' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiClientError, isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' +import { fileStorageStatusContract } from '@/lib/api/contracts/storage-transfer' import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits' import { deleteWorkspaceFileContract, @@ -52,12 +53,15 @@ export const workspaceFilesKeys = { ...(storageKey ? [storageKey] : []), ] as const, storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const, + cloudConfigured: () => [...workspaceFilesKeys.all, 'cloudConfigured'] as const, } export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000 export const WORKSPACE_STORAGE_INFO_STALE_TIME = 60 * 1000 +/** Cloud storage (S3/Blob) is env-driven and does not change at runtime. */ +export const CLOUD_STORAGE_CONFIGURED_STALE_TIME = Number.POSITIVE_INFINITY /** * Storage info type @@ -294,6 +298,25 @@ export function useStorageInfo(enabled = true) { }) } +async function fetchCloudStorageConfigured(signal?: AbortSignal): Promise { + const data = await requestJson(fileStorageStatusContract, { signal }) + return data.cloudConfigured === true +} + +/** + * Whether S3 or Azure Blob is configured. Used by file uploads that need a + * publicly fetchable HTTPS URL (e.g. Instagram publish). + */ +export function useCloudStorageConfigured(enabled = true) { + return useQuery({ + queryKey: workspaceFilesKeys.cloudConfigured(), + queryFn: ({ signal }) => fetchCloudStorageConfigured(signal), + enabled, + retry: false, + staleTime: CLOUD_STORAGE_CONFIGURED_STALE_TIME, + }) +} + /** * Upload workspace file mutation */ diff --git a/apps/sim/lib/api/contracts/oauth-connections.test.ts b/apps/sim/lib/api/contracts/oauth-connections.test.ts new file mode 100644 index 00000000000..a778ddf549f --- /dev/null +++ b/apps/sim/lib/api/contracts/oauth-connections.test.ts @@ -0,0 +1,41 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + instagramAuthorizeQuerySchema, + instagramCallbackQuerySchema, +} from '@/lib/api/contracts/oauth-connections' + +describe('Instagram OAuth query contracts', () => { + it('accepts bounded authorize and callback values', () => { + expect( + instagramAuthorizeQuerySchema.safeParse({ + returnUrl: 'https://sim.ai/workspace/example', + workspaceId: 'workspace-1', + }).success + ).toBe(true) + expect( + instagramCallbackQuerySchema.safeParse({ + code: 'authorization-code', + state: 'oauth-state', + }).success + ).toBe(true) + }) + + it('rejects oversized return URLs before they can be persisted in a cookie', () => { + expect( + instagramAuthorizeQuerySchema.safeParse({ + returnUrl: `https://sim.ai/${'a'.repeat(2048)}`, + }).success + ).toBe(false) + }) + + it('rejects oversized callback fields', () => { + expect(instagramCallbackQuerySchema.safeParse({ code: 'a'.repeat(8193) }).success).toBe(false) + expect(instagramCallbackQuerySchema.safeParse({ state: 'a'.repeat(257) }).success).toBe(false) + expect( + instagramCallbackQuerySchema.safeParse({ error_description: 'a'.repeat(2049) }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index f451f555c1f..1c447b700f3 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -191,6 +191,62 @@ export const trelloCallbackContract = defineRouteContract({ response: { mode: 'text' }, }) +const MAX_OAUTH_RETURN_URL_LENGTH = 2048 +const MAX_OAUTH_CODE_LENGTH = 8192 +const MAX_OAUTH_STATE_LENGTH = 256 +const MAX_OAUTH_ERROR_LENGTH = 2048 + +export const instagramAuthorizeQuerySchema = z.object({ + returnUrl: z + .string() + .min(1, 'Return URL cannot be empty') + .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') + .optional(), + workspaceId: workspaceIdSchema.optional(), +}) + +export const authorizeInstagramContract = defineRouteContract({ + method: 'GET', + path: '/api/auth/instagram/authorize', + query: instagramAuthorizeQuerySchema, + response: { mode: 'redirect' }, +}) + +export const instagramCallbackQuerySchema = z.object({ + code: z + .string() + .min(1, 'Authorization code cannot be empty') + .max(MAX_OAUTH_CODE_LENGTH, 'Authorization code is too long') + .optional(), + state: z + .string() + .min(1, 'OAuth state cannot be empty') + .max(MAX_OAUTH_STATE_LENGTH, 'OAuth state is too long') + .optional(), + error: z + .string() + .min(1, 'OAuth error cannot be empty') + .max(MAX_OAUTH_ERROR_LENGTH, 'OAuth error is too long') + .optional(), + error_reason: z + .string() + .min(1, 'OAuth error reason cannot be empty') + .max(MAX_OAUTH_ERROR_LENGTH, 'OAuth error reason is too long') + .optional(), + error_description: z + .string() + .min(1, 'OAuth error description cannot be empty') + .max(MAX_OAUTH_ERROR_LENGTH, 'OAuth error description is too long') + .optional(), +}) + +export const instagramCallbackContract = defineRouteContract({ + method: 'GET', + path: '/api/auth/oauth2/callback/instagram', + query: instagramCallbackQuerySchema, + response: { mode: 'redirect' }, +}) + export const authorizeOAuth2QuerySchema = z.object({ providerId: z.string().min(1, 'providerId is required'), workspaceId: workspaceIdSchema, diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 4a421203e4a..7f49d9e797e 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -745,6 +745,18 @@ export const fileExportContract = defineRouteContract({ response: { mode: 'binary' }, }) +export const fileStorageStatusResponseSchema = z.object({ + cloudConfigured: z.boolean(), +}) + +export const fileStorageStatusContract = defineRouteContract({ + method: 'GET', + path: '/api/files/storage-status', + response: { mode: 'json', schema: fileStorageStatusResponseSchema }, +}) + +export type FileStorageStatusResponse = ContractJsonResponse + export type BoxUploadBody = ContractBodyInput export type BoxUploadResponse = ContractJsonResponse export type DropboxUploadBody = ContractBodyInput diff --git a/apps/sim/lib/api/contracts/tools/instagram.test.ts b/apps/sim/lib/api/contracts/tools/instagram.test.ts new file mode 100644 index 00000000000..80fbb7109f9 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/instagram.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + instagramCarouselMediaSchema, + instagramDownloadMediaOutputSchema, +} from '@/lib/api/contracts/tools/instagram' + +const userFile = { + id: 'file-1', + name: 'image.jpg', + url: '/api/files/serve/execution/image.jpg', + size: 10, + type: 'image/jpeg', + key: 'execution/workflow-1/execution-1/image.jpg', + context: 'execution', +} + +describe('Instagram tool contracts', () => { + it('accepts only 2-10 carousel files', () => { + expect(instagramCarouselMediaSchema.safeParse([userFile]).success).toBe(false) + expect(instagramCarouselMediaSchema.safeParse([userFile, userFile]).success).toBe(true) + expect(instagramCarouselMediaSchema.safeParse(Array(11).fill(userFile)).success).toBe(false) + expect(instagramCarouselMediaSchema.safeParse('https://example.com/one.jpg').success).toBe( + false + ) + expect( + instagramCarouselMediaSchema.safeParse( + 'https://example.com/one.jpg,https://example.com/two.jpg' + ).success + ).toBe(true) + }) + + it('requires 1-10 downloaded files in successful output', () => { + expect( + instagramDownloadMediaOutputSchema.safeParse({ + files: [], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }).success + ).toBe(false) + expect( + instagramDownloadMediaOutputSchema.safeParse({ + files: [userFile], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 1, + }).success + ).toBe(true) + expect( + instagramDownloadMediaOutputSchema.safeParse({ + files: [userFile], + mediaId: 'media-1', + mediaType: 'IMAGE', + downloadedCount: 2, + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/tools/instagram.ts b/apps/sim/lib/api/contracts/tools/instagram.ts new file mode 100644 index 00000000000..68548cf3730 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/instagram.ts @@ -0,0 +1,236 @@ +import { z } from 'zod' +import { + nonEmptyIdSchema, + userFileSchema, + workflowIdSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' +import { genericToolResponseSchema } from '@/lib/api/contracts/tools/shared' +import type { ContractBodyInput, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { RawFileInputArraySchema, RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' + +const MAX_ACCESS_TOKEN_LENGTH = 8192 +const MAX_GRAPH_ID_LENGTH = 256 +const MAX_MEDIA_URL_LENGTH = 8192 +const MAX_CAROUSEL_INPUT_LENGTH = 100_000 +const MAX_CAPTION_LENGTH = 2200 +const MAX_ALT_TEXT_LENGTH = 1000 + +const instagramOptionalUserIdSchema = z + .string() + .trim() + .max(MAX_GRAPH_ID_LENGTH, 'Instagram user ID is too long') + .optional() + .nullable() + +const instagramOptionalCaptionSchema = z + .string() + .max(MAX_CAPTION_LENGTH, `Caption cannot exceed ${MAX_CAPTION_LENGTH} characters`) + .optional() + .nullable() + +function getCarouselItemCount(value: string): number | null { + if (value.startsWith('[') || value.startsWith('{')) { + try { + const parsed = JSON.parse(value) as unknown + return Array.isArray(parsed) ? parsed.length : 1 + } catch { + return null + } + } + + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean).length +} + +export const instagramAccessTokenSchema = z + .string() + .min(1, 'Access token is required') + .max(MAX_ACCESS_TOKEN_LENGTH, 'Access token is too long') + +export const instagramDownloadMediaBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + mediaId: z.string().trim().min(1, 'Media ID is required').max(256, 'Media ID is too long'), + filename: z + .string() + .trim() + .min(1, 'Filename cannot be empty') + .max(180, 'Filename is too long') + .optional(), + workspaceId: workspaceIdSchema.optional(), + workflowId: workflowIdSchema.optional(), + executionId: nonEmptyIdSchema.optional(), +}) + +export const instagramDownloadMediaOutputSchema = z + .object({ + files: z.array(userFileSchema).min(1, 'At least one downloaded file is required').max(10), + mediaId: z.string().min(1).max(MAX_GRAPH_ID_LENGTH), + mediaType: z.string().max(64).nullable(), + downloadedCount: z.number().int().min(1).max(10), + }) + .superRefine((output, context) => { + if (output.downloadedCount !== output.files.length) { + context.addIssue({ + code: 'custom', + path: ['downloadedCount'], + message: 'Downloaded count must match the number of files', + }) + } + }) + +export const instagramDownloadMediaResponseSchema = z.discriminatedUnion('success', [ + z.object({ + success: z.literal(true), + output: instagramDownloadMediaOutputSchema, + }), + z.object({ + success: z.literal(false), + error: z.string().min(1), + }), +]) + +/** Single media: uploaded file object or public HTTPS URL string. */ +export const instagramMediaInputSchema = z.union([ + RawFileInputSchema, + z.string().trim().min(1, 'Media is required').max(MAX_MEDIA_URL_LENGTH, 'Media URL is too long'), +]) + +/** + * Carousel media: 2-10 files or a legacy comma-separated URL string + * (optional `video:` prefix per entry). + */ +export const instagramCarouselMediaSchema = z.union([ + RawFileInputArraySchema.min(2, 'Carousels require at least 2 items').max( + 10, + 'Carousels support at most 10 items' + ), + z + .string() + .trim() + .min(1, 'Carousel media is required') + .max(MAX_CAROUSEL_INPUT_LENGTH, 'Carousel media input is too long') + .refine( + (value) => { + const itemCount = getCarouselItemCount(value) + return itemCount !== null && itemCount >= 2 && itemCount <= 10 + }, + { message: 'Carousels require between 2 and 10 items' } + ), +]) + +export const instagramPublishImageBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: instagramOptionalUserIdSchema, + image: instagramMediaInputSchema, + caption: instagramOptionalCaptionSchema, + altText: z + .string() + .max(MAX_ALT_TEXT_LENGTH, `Alt text cannot exceed ${MAX_ALT_TEXT_LENGTH} characters`) + .optional() + .nullable(), + isAiGenerated: z.boolean().optional().nullable(), +}) + +export const instagramPublishVideoBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: instagramOptionalUserIdSchema, + video: instagramMediaInputSchema, + cover: instagramMediaInputSchema.optional().nullable(), + caption: instagramOptionalCaptionSchema, +}) + +export const instagramPublishReelBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: instagramOptionalUserIdSchema, + video: instagramMediaInputSchema, + cover: instagramMediaInputSchema.optional().nullable(), + caption: instagramOptionalCaptionSchema, + shareToFeed: z.boolean().optional().nullable(), + thumbOffset: z.number().optional().nullable(), +}) + +export const instagramPublishStoryBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: instagramOptionalUserIdSchema, + media: instagramMediaInputSchema, +}) + +export const instagramPublishCarouselBodySchema = z.object({ + accessToken: instagramAccessTokenSchema, + igUserId: instagramOptionalUserIdSchema, + media: instagramCarouselMediaSchema, + caption: instagramOptionalCaptionSchema, +}) + +export const instagramPublishImageContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-image', + body: instagramPublishImageBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishVideoContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-video', + body: instagramPublishVideoBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishReelContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-reel', + body: instagramPublishReelBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishStoryContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-story', + body: instagramPublishStoryBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramPublishCarouselContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/publish-carousel', + body: instagramPublishCarouselBodySchema, + response: { mode: 'json', schema: genericToolResponseSchema }, +}) + +export const instagramDownloadMediaContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/instagram/download-media', + body: instagramDownloadMediaBodySchema, + response: { mode: 'json', schema: instagramDownloadMediaResponseSchema }, +}) + +export type InstagramDownloadMediaBody = ContractBodyInput +export type InstagramDownloadMediaRouteResponse = ContractJsonResponse< + typeof instagramDownloadMediaContract +> + +export type InstagramPublishImageBody = ContractBodyInput +export type InstagramPublishVideoBody = ContractBodyInput +export type InstagramPublishReelBody = ContractBodyInput +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/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 29f2826026f..61a104eccf2 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -1,5 +1,6 @@ import { createHash } from 'crypto' import { cache } from 'react' +import { getOAuth2Tokens } from '@better-auth/core/oauth2' import { sso } from '@better-auth/sso' import { stripe } from '@better-auth/stripe' import { db } from '@sim/db' @@ -31,6 +32,8 @@ import { renderWelcomeEmail, } from '@/components/emails' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' +import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' +import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' import { sendPlanWelcomeEmail } from '@/lib/billing' import { authorizeSubscriptionReference } from '@/lib/billing/authorization' import { @@ -76,6 +79,10 @@ import { isSsoEnabled, } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' +import { + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' import { processCredentialDraft } from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' @@ -83,20 +90,17 @@ import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email import { quickValidateEmail } from '@/lib/messaging/email/validation' import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server' import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle' -import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' -import { disableUserResources } from '@/lib/workflows/lifecycle' -import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' -import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous' -import { getRequestedSignInProviderId, isSignInProviderAllowed } from './constants' - -const logger = createLogger('Auth') - import { deriveMicrosoftEmailVerified, getMicrosoftRefreshTokenExpiry, isMicrosoftProvider, } from '@/lib/oauth/microsoft' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' +import { captureServerEvent, getPostHogClient } from '@/lib/posthog/server' +import { disableUserResources } from '@/lib/workflows/lifecycle' +import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' + +const logger = createLogger('Auth') /** * Extracts user info from a Microsoft ID token JWT instead of calling Graph API /me. @@ -1947,22 +1951,40 @@ export const auth = betterAuth({ scopes: getCanonicalScopesForProvider('tiktok'), responseType: 'code', redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/tiktok`, - // TikTok requires the app identifier under `client_key`, not the standard - // `client_id`. The library always sends `client_id` too, but TikTok ignores - // unrecognized params, so adding `client_key` here (for both the authorize - // redirect and the token exchange) is sufficient without patching the library. - // - // TikTok also requires a comma-separated `scope` list, but the library always - // joins scopes with a space (no configurable separator in the generic-oauth - // plugin's authorize route). `additionalParams` is applied via - // `url.searchParams.set(key, value)` after the default scope is set, so - // overriding `scope` here replaces the space-joined value with a comma-joined - // one that TikTok can actually parse. authorizationUrlParams: { client_key: env.TIKTOK_CLIENT_ID as string, scope: getCanonicalScopesForProvider('tiktok').join(','), }, - tokenUrlParams: { client_key: env.TIKTOK_CLIENT_ID as string }, + getToken: async ({ code, redirectURI }) => { + const response = await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_key: env.TIKTOK_CLIENT_ID as string, + client_secret: env.TIKTOK_CLIENT_SECRET as string, + code, + grant_type: 'authorization_code', + redirect_uri: redirectURI, + }), + }) + const data = await readResponseJsonWithLimit>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok OAuth token response', + }) + + if (!response.ok || !data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error(`TikTok OAuth token exchange failed with HTTP ${response.status}`) + } + + const tokens = getOAuth2Tokens(data) + if (!tokens.accessToken) { + throw new Error('TikTok OAuth token response did not include an access token') + } + if (typeof data.scope === 'string') { + tokens.scopes = data.scope.split(/[\s,]+/).filter(Boolean) + } + return tokens + }, getUserInfo: async (tokens) => { try { const response = await fetch( @@ -1975,7 +1997,10 @@ export const auth = betterAuth({ ) if (!response.ok) { - await response.text().catch(() => {}) + await readResponseTextWithLimit(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile error response', + }).catch(() => {}) logger.error('Error fetching TikTok user info:', { status: response.status, statusText: response.statusText, @@ -1983,7 +2008,18 @@ export const auth = betterAuth({ return null } - const profile = await response.json() + const profile = await readResponseJsonWithLimit<{ + data?: { + user?: { + avatar_url?: string + display_name?: string + open_id?: string + } + } + }>(response, { + maxBytes: 1024 * 1024, + label: 'TikTok profile response', + }) const user = profile.data?.user if (!user?.open_id) { diff --git a/apps/sim/lib/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 6fcd4dd7c75..12944e93ae6 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -406,6 +406,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/instagram/constants.ts b/apps/sim/lib/integrations/instagram/constants.ts new file mode 100644 index 00000000000..a45c3641448 --- /dev/null +++ b/apps/sim/lib/integrations/instagram/constants.ts @@ -0,0 +1,2 @@ +export const INSTAGRAM_GRAPH_VERSION = 'v25.0' +export const INSTAGRAM_GRAPH_BASE = `https://graph.instagram.com/${INSTAGRAM_GRAPH_VERSION}` diff --git a/apps/sim/lib/integrations/instagram/resolve-media.ts b/apps/sim/lib/integrations/instagram/resolve-media.ts new file mode 100644 index 00000000000..83208f6a71b --- /dev/null +++ b/apps/sim/lib/integrations/instagram/resolve-media.ts @@ -0,0 +1,374 @@ +import type { Logger } from '@sim/logger' +import { hasCloudStorage } from '@/lib/uploads/core/storage-service' +import { + getFileExtension, + getMimeTypeFromExtension, + isInternalFileUrl, + type RawFileInput, + resolveFileType, +} from '@/lib/uploads/utils/file-utils' +import { resolveFileInputToUrl } from '@/lib/uploads/utils/file-utils.server' + +/** Covers Meta's poll-once-per-minute for ≤5 minutes while the container processes. */ +export const INSTAGRAM_MEDIA_URL_TTL_SECONDS = 600 + +const IMAGE_MAX_BYTES = 8 * 1024 * 1024 +const REEL_VIDEO_MAX_BYTES = 300 * 1024 * 1024 +const STORY_VIDEO_MAX_BYTES = 100 * 1024 * 1024 + +const JPEG_MIME = new Set(['image/jpeg', 'image/jpg']) +const VIDEO_MIME = new Set(['video/mp4', 'video/quicktime']) +const JPEG_EXT = new Set(['jpg', 'jpeg']) +const VIDEO_EXT = new Set(['mp4', 'mov']) + +const CLOUD_STORAGE_REQUIRED_MESSAGE = + 'Cloud storage is required to publish uploaded Instagram media. Configure S3_BUCKET_NAME and AWS_REGION (or Azure Blob AZURE_STORAGE_* vars), or paste a public HTTPS URL instead.' + +export type InstagramMediaRole = 'image' | 'video' | 'cover' | 'story' | 'carousel' + +export interface ResolvedInstagramMedia { + url: string + kind: 'image' | 'video' + mimeType?: string + size?: number + name?: string +} + +export interface ResolveInstagramMediaResult { + media?: ResolvedInstagramMedia + error?: { status: number; message: string } +} + +export interface ResolveInstagramMediaOptions { + input: unknown + userId: string + requestId: string + logger: Logger + role: InstagramMediaRole + required?: boolean + label?: string +} + +function formatBytes(bytes: number): string { + if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}MB` + return `${bytes} bytes` +} + +function extensionFromUrlOrName(value?: string): string { + if (!value) return '' + try { + const pathname = + value.startsWith('http://') || value.startsWith('https://') ? new URL(value).pathname : value + return getFileExtension(pathname.split('?')[0] || '') + } catch { + return getFileExtension(value) + } +} + +function inferKindFromMimeOrExt( + mimeType: string | undefined, + extension: string +): 'image' | 'video' | null { + const mime = (mimeType || '').toLowerCase() + if (mime.startsWith('image/') || JPEG_EXT.has(extension)) return 'image' + if (mime.startsWith('video/') || VIDEO_EXT.has(extension)) return 'video' + return null +} + +function validateMediaConstraints( + kind: 'image' | 'video', + role: InstagramMediaRole, + mimeType: string | undefined, + size: number | undefined, + label: string +): string | null { + const mime = (mimeType || '').toLowerCase() + + if (kind === 'image') { + if (mime && !JPEG_MIME.has(mime)) { + return `${label} must be a JPEG image (got ${mime})` + } + if (size != null && size > IMAGE_MAX_BYTES) { + return `${label} exceeds Instagram's ${formatBytes(IMAGE_MAX_BYTES)} JPEG limit (got ${formatBytes(size)})` + } + return null + } + + if (mime && !VIDEO_MIME.has(mime)) { + return `${label} must be an MP4 or MOV video (got ${mime})` + } + + const maxBytes = role === 'story' ? STORY_VIDEO_MAX_BYTES : REEL_VIDEO_MAX_BYTES + if (size != null && size > maxBytes) { + return `${label} exceeds Instagram's ${formatBytes(maxBytes)} video limit for ${role} (got ${formatBytes(size)})` + } + return null +} + +function isPublicHttpUrl(value: string): boolean { + return (value.startsWith('https://') || value.startsWith('http://')) && !isInternalFileUrl(value) +} + +function needsCloudStorage(file?: RawFileInput, filePath?: string): boolean { + if (file) return true + if (filePath && isInternalFileUrl(filePath)) return true + return false +} + +/** + * Split a canonical media input into the shapes {@link resolveFileInputToUrl} expects + * (Reducto/STT pattern: file object vs filePath string). + */ +function splitMediaInput(input: unknown): { + file?: RawFileInput + filePath?: string + name?: string + size?: number + mimeType?: string + error?: { status: number; message: string } +} { + if (typeof input === 'string') { + return { filePath: input.trim() } + } + + if (typeof input === 'object' && input !== null && !Array.isArray(input)) { + const raw = input as RawFileInput + if (!raw.name) { + return { error: { status: 400, message: 'File must include a name' } } + } + return { + file: raw, + name: raw.name, + size: typeof raw.size === 'number' ? raw.size : undefined, + mimeType: resolveFileType({ type: raw.type || '', name: raw.name }), + } + } + + return { error: { status: 400, message: 'Media must be a file or URL string' } } +} + +function applyInstagramConstraints( + url: string, + role: InstagramMediaRole, + label: string, + meta: { name?: string; size?: number; mimeType?: string } +): ResolveInstagramMediaResult { + const mimeType = + meta.mimeType || getMimeTypeFromExtension(extensionFromUrlOrName(meta.name || url)) + const extension = extensionFromUrlOrName(meta.name || url) + + let kind: 'image' | 'video' + if (role === 'image' || role === 'cover') { + kind = 'image' + } else if (role === 'video') { + kind = 'video' + } else { + const inferred = inferKindFromMimeOrExt(mimeType, extension) + if (!inferred) { + return { + error: { + status: 400, + message: `${label} must be a JPEG image or MP4/MOV video`, + }, + } + } + kind = inferred + } + + if ( + (role === 'story' || role === 'carousel') && + (!mimeType || mimeType === 'application/octet-stream') + ) { + if (JPEG_EXT.has(extension)) kind = 'image' + else if (VIDEO_EXT.has(extension)) kind = 'video' + } + + const constraintError = validateMediaConstraints( + kind, + role, + mimeType === 'application/octet-stream' ? undefined : mimeType, + meta.size, + label + ) + if (constraintError) { + return { error: { status: 400, message: constraintError } } + } + + if (kind === 'image' && extension && !JPEG_EXT.has(extension) && !mimeType) { + return { + error: { status: 400, message: `${label} must be a JPEG (.jpg/.jpeg)` }, + } + } + if (kind === 'video' && extension && !VIDEO_EXT.has(extension) && !mimeType) { + return { + error: { status: 400, message: `${label} must be an MP4 or MOV video` }, + } + } + + return { + media: { + url, + kind, + mimeType, + size: meta.size, + name: meta.name, + }, + } +} + +/** + * Resolve a UserFile, internal serve path, or public HTTPS URL into a Meta-fetchable HTTPS URL. + * Delegates UserFile serialization and URL minting to {@link resolveFileInputToUrl} + * (600s TTL, prefer key presign). Instagram-specific MIME/size checks stay here. + */ +export async function resolveInstagramMedia( + options: ResolveInstagramMediaOptions +): Promise { + const { input, userId, requestId, logger, role, required = true, label = 'Media' } = options + + if (input == null || input === '') { + if (required) { + return { error: { status: 400, message: `${label} is required` } } + } + return {} + } + + const split = splitMediaInput(input) + if (split.error) { + return { error: split.error } + } + + const { file, filePath, name, size, mimeType } = split + if (filePath !== undefined && filePath === '') { + if (required) { + return { error: { status: 400, message: `${label} is required` } } + } + return {} + } + + if (needsCloudStorage(file, filePath) && !hasCloudStorage()) { + return { error: { status: 400, message: CLOUD_STORAGE_REQUIRED_MESSAGE } } + } + + 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, + presignExpirySeconds: INSTAGRAM_MEDIA_URL_TTL_SECONDS, + }) + + 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 { + return { error: { status: 400, message: 'Carousel media JSON is invalid' } } + } + } + + 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/oauth-service.test.ts b/apps/sim/lib/integrations/oauth-service.test.ts index 4cdf616ae4a..3e7614acc5d 100644 --- a/apps/sim/lib/integrations/oauth-service.test.ts +++ b/apps/sim/lib/integrations/oauth-service.test.ts @@ -43,6 +43,7 @@ const EXPECTED_PROVIDER_BY_SLUG: Record = { jira: 'jira', 'jira-service-management': 'jira', linear: 'linear', + instagram: 'instagram', linkedin: 'linkedin', 'microsoft-dataverse': 'microsoft-dataverse', 'microsoft-excel': 'microsoft-excel', diff --git a/apps/sim/lib/oauth/index.ts b/apps/sim/lib/oauth/index.ts index f4c641730ea..8e075a51fb8 100644 --- a/apps/sim/lib/oauth/index.ts +++ b/apps/sim/lib/oauth/index.ts @@ -1,3 +1,4 @@ +export * from './instagram' export * from './microsoft' export * from './oauth' export * from './types' diff --git a/apps/sim/lib/oauth/instagram.test.ts b/apps/sim/lib/oauth/instagram.test.ts new file mode 100644 index 00000000000..de3358991f0 --- /dev/null +++ b/apps/sim/lib/oauth/instagram.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { + INSTAGRAM_MIN_TOKEN_AGE_MS, + INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS, + isInstagramProvider, + parseInstagramLongLivedToken, + parseInstagramProfile, + parseInstagramShortLivedToken, + shouldProactivelyRefreshInstagramToken, +} from '@/lib/oauth/instagram' + +describe('instagram oauth helpers', () => { + it('identifies the instagram provider', () => { + expect(isInstagramProvider('instagram')).toBe(true) + expect(isInstagramProvider('facebook')).toBe(false) + }) + + it('does not refresh when the token is already expired', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date(now.getTime() - 60_000), + updatedAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + now, + }) + ).toBe(false) + }) + + it('does not refresh tokens younger than 24 hours', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000), + updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS + 60_000), + now, + }) + ).toBe(false) + }) + + it('refreshes when expiry is within the proactive window and the token is old enough', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date( + now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS - 1) * 24 * 60 * 60 * 1000 + ), + updatedAt: new Date(now.getTime() - INSTAGRAM_MIN_TOKEN_AGE_MS - 60_000), + now, + }) + ).toBe(true) + }) + + it('does not refresh when plenty of lifetime remains', () => { + const now = new Date('2026-07-11T12:00:00.000Z') + expect( + shouldProactivelyRefreshInstagramToken({ + accessTokenExpiresAt: new Date( + now.getTime() + (INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS + 5) * 24 * 60 * 60 * 1000 + ), + updatedAt: new Date(now.getTime() - 40 * 24 * 60 * 60 * 1000), + now, + }) + ).toBe(false) + }) + + it('parses direct and data-array short-lived token responses', () => { + expect( + parseInstagramShortLivedToken({ + access_token: 'short-token', + user_id: 123, + permissions: ['instagram_business_basic'], + }) + ).toEqual({ + access_token: 'short-token', + user_id: 123, + permissions: ['instagram_business_basic'], + }) + expect( + parseInstagramShortLivedToken({ data: [{ access_token: 'wrapped-token', user_id: '456' }] }) + ).toEqual({ access_token: 'wrapped-token', user_id: '456' }) + }) + + it('rejects malformed or oversized token responses', () => { + expect(parseInstagramShortLivedToken({ access_token: 123 })).toBeNull() + expect( + parseInstagramLongLivedToken({ access_token: 'token', expires_in: '5184000' }) + ).toBeNull() + expect(parseInstagramLongLivedToken({ access_token: 'token' })).toBeNull() + expect( + parseInstagramLongLivedToken({ + access_token: 'token', + expires_in: 366 * 24 * 60 * 60, + }) + ).toBeNull() + }) + + it('parses bounded long-lived token and profile responses', () => { + expect( + parseInstagramLongLivedToken({ + access_token: 'long-token', + token_type: 'bearer', + expires_in: 5_184_000, + }) + ).toEqual({ + access_token: 'long-token', + token_type: 'bearer', + expires_in: 5_184_000, + }) + expect( + parseInstagramProfile({ user_id: 123, id: 'graph-id', username: 'sim', name: 'Sim' }) + ).toEqual({ user_id: 123, id: 'graph-id', username: 'sim', name: 'Sim' }) + expect(parseInstagramProfile({ user_id: { nested: true } })).toBeNull() + }) +}) diff --git a/apps/sim/lib/oauth/instagram.ts b/apps/sim/lib/oauth/instagram.ts new file mode 100644 index 00000000000..34623bf285e --- /dev/null +++ b/apps/sim/lib/oauth/instagram.ts @@ -0,0 +1,107 @@ +import { z } from 'zod' + +/** Refresh while the long-lived token still has this many days left. */ +export const INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS = 14 + +/** + * Meta rejects refresh until the long-lived token is at least 24 hours old. + * @see https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token/ + */ +export const INSTAGRAM_MIN_TOKEN_AGE_MS = 24 * 60 * 60 * 1000 + +const INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH = 8192 +const INSTAGRAM_GRAPH_ID_MAX_LENGTH = 256 +const INSTAGRAM_PERMISSION_MAX_LENGTH = 256 +const INSTAGRAM_PERMISSION_COUNT_MAX = 64 +const INSTAGRAM_PROFILE_TEXT_MAX_LENGTH = 512 +const INSTAGRAM_TOKEN_TYPE_MAX_LENGTH = 64 +const INSTAGRAM_TOKEN_LIFETIME_MAX_SECONDS = 365 * 24 * 60 * 60 + +const instagramGraphIdSchema = z.union([ + z.string().min(1).max(INSTAGRAM_GRAPH_ID_MAX_LENGTH), + z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), +]) + +const instagramShortLivedTokenSchema = z.object({ + access_token: z.string().min(1).max(INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH), + user_id: instagramGraphIdSchema.optional(), + permissions: z + .union([ + z.string().min(1).max(INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH), + z + .array(z.string().min(1).max(INSTAGRAM_PERMISSION_MAX_LENGTH)) + .max(INSTAGRAM_PERMISSION_COUNT_MAX), + ]) + .optional(), +}) + +const instagramShortLivedTokenResponseSchema = z.union([ + instagramShortLivedTokenSchema, + z.object({ data: z.array(instagramShortLivedTokenSchema).length(1) }), +]) + +export const instagramLongLivedTokenResponseSchema = z.object({ + access_token: z.string().min(1).max(INSTAGRAM_ACCESS_TOKEN_MAX_LENGTH), + token_type: z.string().min(1).max(INSTAGRAM_TOKEN_TYPE_MAX_LENGTH).optional(), + expires_in: z.number().int().positive().max(INSTAGRAM_TOKEN_LIFETIME_MAX_SECONDS), +}) + +export const instagramProfileResponseSchema = z.object({ + user_id: instagramGraphIdSchema.optional(), + id: instagramGraphIdSchema.optional(), + username: z.string().max(INSTAGRAM_PROFILE_TEXT_MAX_LENGTH).optional(), + name: z.string().max(INSTAGRAM_PROFILE_TEXT_MAX_LENGTH).optional(), +}) + +export type InstagramShortLivedToken = z.output +export type InstagramLongLivedToken = z.output +export type InstagramProfile = z.output + +/** Parses the direct or legacy data-array shape returned by Instagram's code exchange. */ +export function parseInstagramShortLivedToken(value: unknown): InstagramShortLivedToken | null { + const parsed = instagramShortLivedTokenResponseSchema.safeParse(value) + if (!parsed.success) return null + return 'data' in parsed.data ? parsed.data.data[0] : parsed.data +} + +/** Parses and bounds the long-lived access-token exchange response. */ +export function parseInstagramLongLivedToken(value: unknown): InstagramLongLivedToken | null { + const parsed = instagramLongLivedTokenResponseSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +/** Parses the subset of the Instagram profile response persisted during OAuth. */ +export function parseInstagramProfile(value: unknown): InstagramProfile | null { + const parsed = instagramProfileResponseSchema.safeParse(value) + return parsed.success ? parsed.data : null +} + +export function isInstagramProvider(providerId: string): boolean { + return providerId === 'instagram' +} + +/** + * Whether an Instagram long-lived token should be refreshed before it expires. + * Meta cannot refresh expired tokens, so we must refresh while still valid. + */ +export function shouldProactivelyRefreshInstagramToken(options: { + accessTokenExpiresAt?: Date | null + updatedAt?: Date | null + now?: Date +}): boolean { + const now = options.now ?? new Date() + const expiresAt = options.accessTokenExpiresAt + if (!expiresAt || expiresAt <= now) { + return false + } + + const updatedAt = options.updatedAt + if (updatedAt && now.getTime() - updatedAt.getTime() < INSTAGRAM_MIN_TOKEN_AGE_MS) { + return false + } + + const proactiveThreshold = new Date( + now.getTime() + INSTAGRAM_PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000 + ) + return expiresAt <= proactiveThreshold +} diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index a5ad87e2f2b..339b1a7694a 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -9,6 +9,10 @@ vi.mock('@/lib/core/config/env', () => GITHUB_CLIENT_SECRET: 'github_client_secret', X_CLIENT_ID: 'x_client_id', X_CLIENT_SECRET: 'x_client_secret', + TIKTOK_CLIENT_ID: 'tiktok_client_key', + TIKTOK_CLIENT_SECRET: 'tiktok_client_secret', + INSTAGRAM_CLIENT_ID: 'instagram_client_id', + INSTAGRAM_CLIENT_SECRET: 'instagram_client_secret', CONFLUENCE_CLIENT_ID: 'confluence_client_id', CONFLUENCE_CLIENT_SECRET: 'confluence_client_secret', JIRA_CLIENT_ID: 'jira_client_id', @@ -52,6 +56,7 @@ vi.mock('@/lib/core/config/env', () => }) ) +import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' import { refreshOAuthToken } from '@/lib/oauth' /** @@ -271,6 +276,22 @@ describe('OAuth Token Refresh', () => { ) }) + it.concurrent('should refresh TikTok with client_key instead of client_id', async () => { + const mockFetch = createMockFetch(defaultOAuthResponse) + const refreshToken = 'test_refresh_token' + + await withMockFetch(mockFetch, () => refreshOAuthToken('tiktok', refreshToken)) + + const [endpoint, requestOptions] = mockFetch.mock.calls[0] as [string, { body: string }] + const bodyParams = new URLSearchParams(requestOptions.body) + + expect(endpoint).toBe('https://open.tiktokapis.com/v2/oauth/token/') + expect(bodyParams.get('client_key')).toBe('tiktok_client_key') + expect(bodyParams.get('client_secret')).toBe('tiktok_client_secret') + expect(bodyParams.get('refresh_token')).toBe(refreshToken) + expect(bodyParams.get('client_id')).toBeNull() + }) + it.concurrent('should send Notion request with Basic Auth header and JSON body', async () => { const mockFetch = createMockFetch(defaultOAuthResponse) const refreshToken = 'test_refresh_token' @@ -507,4 +528,80 @@ describe('OAuth Token Refresh', () => { }) }) }) + + describe('Instagram Token Refresh', () => { + it.concurrent('validates and rotates the long-lived token response', async () => { + const mockFetch = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'new_instagram_access_token', + token_type: 'bearer', + expires_in: 5_184_000, + }) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result).toEqual({ + ok: true, + accessToken: 'new_instagram_access_token', + expiresIn: 5_184_000, + refreshToken: 'new_instagram_access_token', + }) + expect(mockFetch).toHaveBeenCalledWith( + 'https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=old_instagram_access_token', + expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }) + ) + }) + + it.concurrent('rejects malformed long-lived token response fields', async () => { + const mockFetch = vi.fn().mockResolvedValue( + Response.json({ + access_token: 'new_instagram_access_token', + expires_in: '5184000', + }) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result).toEqual({ + ok: false, + message: 'Invalid Instagram token refresh response', + }) + }) + + it.concurrent('extracts nested Meta error codes from a bounded response', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue( + Response.json( + { error: { message: 'Invalid OAuth access token', type: 'OAuthException', code: 190 } }, + { status: 400 } + ) + ) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.errorCode).toBe('190') + }) + + it.concurrent('rejects oversized token responses before materializing them', async () => { + const mockFetch = vi + .fn() + .mockResolvedValue(new Response('x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1))) + + const result = await withMockFetch(mockFetch, () => + refreshOAuthToken('instagram', 'old_instagram_access_token') + ) + + expect(result.ok).toBe(false) + if (!result.ok) expect(result.message).toContain('exceeds maximum size') + }) + }) }) diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index d472d8d611c..e93ec7be5c4 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' import { AirtableIcon, AsanaIcon, @@ -24,6 +25,7 @@ import { GoogleSheetsIcon, GoogleTasksIcon, HubspotIcon, + InstagramIcon, JiraIcon, LinearIcon, LinkedInIcon, @@ -53,6 +55,11 @@ import { ZoomIcon, } from '@/components/icons' import { env } from '@/lib/core/config/env' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { parseInstagramLongLivedToken } from '@/lib/oauth/instagram' import type { OAuthProviderConfig } from './types' const logger = createLogger('OAuth') @@ -978,6 +985,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, @@ -1087,6 +1115,12 @@ interface ProviderAuthConfig { * instead of the default application/x-www-form-urlencoded. Used by Notion. */ useJsonBody?: boolean + /** + * Token refresh strategy. `instagram_long_lived` uses Meta's GET + * `refresh_access_token?grant_type=ig_refresh_token` flow instead of a + * standard OAuth refresh_token POST. + */ + refreshStrategy?: 'standard' | 'instagram_long_lived' /** * Body param name to use for the client identifier instead of the standard `client_id`. * TikTok requires `client_key` instead. @@ -1390,6 +1424,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, @@ -1547,8 +1595,12 @@ export type RefreshTokenResult = RefreshTokenSuccess | RefreshTokenFailure function extractErrorCode(value: unknown): string | undefined { if (value && typeof value === 'object' && 'error' in value) { - const code = (value as { error: unknown }).error - if (typeof code === 'string') return code + const error = (value as { error: unknown }).error + if (typeof error === 'string') return error + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code: unknown }).code + if (typeof code === 'string' || typeof code === 'number') return String(code) + } } return undefined } @@ -1562,6 +1614,68 @@ 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), + }) + + const responseText = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram token refresh response', + }) + let responseData: unknown = responseText + try { + responseData = JSON.parse(responseText) + } catch { + responseData = responseText + } + + if (!response.ok) { + const errorSummary = truncate(responseText, 1000) + logger.error('Instagram long-lived token refresh failed:', { + status: response.status, + statusText: response.statusText, + error: errorSummary, + parsedError: responseData, + providerId, + tokenEndpoint: config.tokenEndpoint, + }) + return { + ok: false, + errorCode: extractErrorCode(responseData), + message: `Failed to refresh token: ${response.status} ${errorSummary}`, + } + } + + const payload = parseInstagramLongLivedToken(responseData) + if (!payload) { + logger.warn('Invalid Instagram refresh response', { providerId }) + return { ok: false, message: 'Invalid Instagram token refresh response' } + } + + logger.info('Instagram long-lived token refreshed successfully', { + expiresIn: payload.expires_in, + providerId, + }) + + // Instagram returns a new long-lived token; store it as both access and refresh. + return { + ok: true, + accessToken: payload.access_token, + expiresIn: payload.expires_in, + refreshToken: payload.access_token, + } +} + export async function refreshOAuthToken( providerId: string, refreshToken: string @@ -1571,6 +1685,10 @@ export async function refreshOAuthToken( const config = getProviderAuthConfig(provider) + if (config.refreshStrategy === 'instagram_long_lived') { + return await refreshInstagramLongLivedToken(config, refreshToken, providerId) + } + const { headers, bodyParams, useJsonBody } = buildAuthRequest(config, refreshToken) const response = await fetch(config.tokenEndpoint, { diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 35d08c21e84..96b7f1a06eb 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -77,6 +77,7 @@ export type OAuthProvider = | 'hubspot' | 'salesforce' | 'linkedin' + | 'instagram' | 'shopify' | 'zoom' | 'wordpress' @@ -129,6 +130,7 @@ export type OAuthService = | 'hubspot' | 'salesforce' | 'linkedin' + | 'instagram' | 'shopify' | 'zoom' | 'wordpress' diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index 92d48a611fa..7960aad288e 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -359,6 +359,13 @@ export const SCOPE_DESCRIPTIONS: Record = { // LinkedIn scopes w_member_social: 'Access LinkedIn profile', + // Instagram scopes (Business Login for Instagram) + instagram_business_basic: 'Access Instagram professional profile and media', + instagram_business_content_publish: 'Publish photos, videos, reels, and stories', + instagram_business_manage_comments: 'Read, reply to, hide, and delete comments', + instagram_business_manage_messages: 'Read conversations and send Instagram Direct messages', + instagram_business_manage_insights: 'Read account and media insights', + // Box scopes root_readwrite: 'Read and write all files and folders in Box account', root_readonly: 'Read all files and folders in Box account', diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 31ce9b6c0b7..cc230df8108 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -176,6 +176,8 @@ export interface DownloadFileFromUrlOptions { timeoutMs?: number /** Hard cap on the number of bytes read from the source. */ maxBytes?: number + /** Cancels an external download when the surrounding request or execution stops. */ + signal?: AbortSignal /** * Principal the download is performed on behalf of. Required to authorize * internal (`/api/files/serve/...`) URLs: the resolved storage key is checked @@ -203,7 +205,9 @@ export async function downloadFileFromUrl( fileUrl: string, options: DownloadFileFromUrlOptions = {} ): Promise { - const { timeoutMs = getMaxExecutionTimeout(), maxBytes, userId } = options + const { timeoutMs = getMaxExecutionTimeout(), maxBytes, signal, userId } = options + + signal?.throwIfAborted() if (isInternalFileUrl(fileUrl)) { if (!userId) { @@ -237,6 +241,7 @@ export async function downloadFileFromUrl( const response = await secureFetchWithPinnedIP(fileUrl, urlValidation.resolvedIP!, { timeout: timeoutMs, maxResponseBytes: maxBytes, + signal, }) if (!response.ok) { diff --git a/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts b/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts deleted file mode 100644 index e0ac0d04b84..00000000000 --- a/apps/sim/lib/webhooks/providers/tiktok-targets.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCredentialExpression, mockEq, mockSelect, queryRows } = vi.hoisted(() => ({ - mockCredentialExpression: vi.fn(() => 'webhook.credentialId'), - mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })), - mockSelect: vi.fn(), - queryRows: { - rows: [] as Array<{ - accountId: string - webhook: Record - workflow: Record - }>, - }, -})) - -vi.mock('@sim/db', () => { - const chain = { - from: vi.fn(() => chain), - innerJoin: vi.fn(() => chain), - leftJoin: vi.fn(() => chain), - where: vi.fn(() => Promise.resolve(queryRows.rows)), - } - mockSelect.mockImplementation(() => chain) - - return { - account: { - id: 'account.id', - accountId: 'account.accountId', - providerId: 'account.providerId', - }, - credential: { - id: 'credential.id', - accountId: 'credential.accountId', - providerId: 'credential.providerId', - type: 'credential.type', - workspaceId: 'credential.workspaceId', - }, - db: { select: mockSelect }, - webhookCredentialIdExpression: mockCredentialExpression, - webhook: { - deploymentVersionId: 'webhook.deploymentVersionId', - isActive: 'webhook.isActive', - archivedAt: 'webhook.archivedAt', - provider: 'webhook.provider', - providerConfig: 'webhook.providerConfig', - workflowId: 'webhook.workflowId', - }, - workflow: { - id: 'workflow.id', - workspaceId: 'workflow.workspaceId', - archivedAt: 'workflow.archivedAt', - }, - workflowDeploymentVersion: { - id: 'workflowDeploymentVersion.id', - workflowId: 'workflowDeploymentVersion.workflowId', - isActive: 'workflowDeploymentVersion.isActive', - }, - } -}) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => conditions), - eq: mockEq, - isNull: vi.fn((value: unknown) => ({ isNull: value })), - like: vi.fn((left: unknown, right: unknown) => ({ left, right })), - or: vi.fn((...conditions: unknown[]) => conditions), -})) - -import { findTikTokWebhookTargets } from '@/lib/webhooks/providers/tiktok-targets' - -describe('findTikTokWebhookTargets', () => { - beforeEach(() => { - vi.clearAllMocks() - queryRows.rows = [] - }) - - it('returns only rows whose stored account ID exactly matches user_openid', async () => { - queryRows.rows = [ - { - accountId: 'act.user-11111111-2222-3333-4444-555555555555', - webhook: { id: 'webhook-1' }, - workflow: { id: 'workflow-1' }, - }, - { - accountId: 'act.user-other-11111111-2222-3333-4444-555555555555', - webhook: { id: 'webhook-2' }, - workflow: { id: 'workflow-2' }, - }, - ] - - const targets = await findTikTokWebhookTargets('act.user', 'request-1') - - expect(targets).toEqual([ - { - webhook: { id: 'webhook-1' }, - workflow: { id: 'workflow-1' }, - }, - ]) - }) - - it('enforces provider and workspace bindings in the database query', async () => { - await findTikTokWebhookTargets('act.user', 'request-2') - - expect(mockEq).toHaveBeenCalledWith('credential.providerId', 'tiktok') - expect(mockEq).toHaveBeenCalledWith('webhook.provider', 'tiktok') - expect(mockEq).toHaveBeenCalledWith('workflow.workspaceId', 'credential.workspaceId') - expect(mockCredentialExpression).toHaveBeenCalledWith('webhook.providerConfig') - expect(mockEq).toHaveBeenCalledWith('webhook.credentialId', 'credential.id') - }) - - it('does not query for an empty user_openid', async () => { - expect(await findTikTokWebhookTargets('', 'request-3')).toEqual([]) - expect(mockSelect).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/tools/instagram/delete_comment.ts b/apps/sim/tools/instagram/delete_comment.ts new file mode 100644 index 00000000000..f3216b15c9a --- /dev/null +++ b/apps/sim/tools/instagram/delete_comment.ts @@ -0,0 +1,77 @@ +import type { + InstagramDeleteCommentParams, + InstagramDeleteCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError, readGraphJson } 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), + } + } + + if (response.status === 204) { + return { success: true, output: { success: true } } + } + + const data = await readGraphJson<{ success?: boolean }>( + response, + 'Instagram delete comment response' + ).catch(() => null) + if (!data || data.success !== true) { + return { + success: false, + output: { success: false }, + error: 'Instagram did not confirm that the comment was deleted', + } + } + + return { + success: true, + output: { success: true }, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the delete succeeded' }, + }, +} diff --git a/apps/sim/tools/instagram/download_media.ts b/apps/sim/tools/instagram/download_media.ts new file mode 100644 index 00000000000..2c8d15f8ac8 --- /dev/null +++ b/apps/sim/tools/instagram/download_media.ts @@ -0,0 +1,140 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { + type InstagramDownloadMediaBody, + instagramDownloadMediaResponseSchema, +} from '@/lib/api/contracts/tools/instagram' +import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits' +import type { + InstagramDownloadMediaParams, + InstagramDownloadMediaResponse, +} from '@/tools/instagram/types' +import type { ToolConfig } from '@/tools/types' + +const MAX_INTERNAL_RESPONSE_BYTES = 256 * 1024 + +function failureOutput(mediaId: string): InstagramDownloadMediaResponse['output'] { + return { + files: [], + mediaId, + mediaType: null, + downloadedCount: 0, + } +} + +export const instagramDownloadMediaTool: ToolConfig< + InstagramDownloadMediaParams, + InstagramDownloadMediaResponse +> = { + id: 'instagram_download_media', + name: 'Instagram Download Media', + description: + 'Download Instagram media into canonical User Files for downstream file inputs (100 MB max per file)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media ID to download', + }, + filename: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional filename override; carousel items receive an ordered suffix', + }, + }, + + request: { + url: '/api/tools/instagram/download-media', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => + ({ + accessToken: params.accessToken, + mediaId: params.mediaId, + filename: params.filename, + workspaceId: + typeof params._context?.workspaceId === 'string' + ? params._context.workspaceId + : undefined, + workflowId: + typeof params._context?.workflowId === 'string' ? params._context.workflowId : undefined, + executionId: + typeof params._context?.executionId === 'string' + ? params._context.executionId + : undefined, + }) satisfies InstagramDownloadMediaBody, + }, + + transformResponse: async (response, params): Promise => { + const mediaId = params?.mediaId?.trim() ?? '' + + try { + const rawData = await readResponseJsonWithLimit(response, { + maxBytes: MAX_INTERNAL_RESPONSE_BYTES, + label: 'Instagram download media response', + }) + const parsed = instagramDownloadMediaResponseSchema.safeParse(rawData) + + if (!parsed.success) { + return { + success: false, + output: failureOutput(mediaId), + error: 'Instagram download route returned an invalid response', + } + } + + if (!response.ok || !parsed.data.success) { + return { + success: false, + output: failureOutput(mediaId), + error: + parsed.data.success === false + ? parsed.data.error + : `Instagram download failed with status ${response.status}`, + } + } + + return { + success: true, + output: parsed.data.output, + } + } catch (error) { + return { + success: false, + output: failureOutput(mediaId), + error: getErrorMessage(error, 'Failed to read Instagram download response'), + } + } + }, + + outputs: { + files: { + type: 'file[]', + description: + 'Downloaded media as canonical User Files, ready for attachment inputs (100 MB max each)', + }, + mediaId: { type: 'string', description: 'Instagram media ID that was downloaded' }, + mediaType: { + type: 'string', + description: 'Instagram media type, such as IMAGE, VIDEO, or CAROUSEL_ALBUM', + optional: true, + }, + downloadedCount: { type: 'number', description: 'Number of files downloaded' }, + }, +} diff --git a/apps/sim/tools/instagram/get_account_insights.ts b/apps/sim/tools/instagram/get_account_insights.ts new file mode 100644 index 00000000000..28bb2a5b0bf --- /dev/null +++ b/apps/sim/tools/instagram/get_account_insights.ts @@ -0,0 +1,141 @@ +import { INSTAGRAM_INSIGHT_PROPERTIES } from '@/tools/instagram/output-properties' +import type { + InstagramGetAccountInsightsParams, + InstagramGetAccountInsightsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + parseCommaSeparated, + readGraphError, + readGraphJson, +} 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 current metrics (e.g. reach,views,accounts_engaged,likes,comments,saves,shares,total_interactions)', + }, + period: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Use day for interaction metrics or lifetime for demographic metrics', + }, + 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 for demographic metrics: this_week or this_month', + }, + }, + + 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 readGraphJson<{ data?: Record[] }>( + response, + 'Instagram account insights response' + ) + 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: 'array', + description: 'Account insight metrics', + items: { type: 'object', properties: INSTAGRAM_INSIGHT_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/instagram/get_container_status.ts b/apps/sim/tools/instagram/get_container_status.ts new file mode 100644 index 00000000000..851752ec94d --- /dev/null +++ b/apps/sim/tools/instagram/get_container_status.ts @@ -0,0 +1,90 @@ +import type { + InstagramGetContainerStatusParams, + InstagramGetContainerStatusResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + idString, + readGraphError, + readGraphJson, +} 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 readGraphJson<{ + id?: string | number + status_code?: string + status?: string + }>(response, 'Instagram container status response') + 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.test.ts b/apps/sim/tools/instagram/get_conversation_messages.test.ts new file mode 100644 index 00000000000..3eedc79fcbc --- /dev/null +++ b/apps/sim/tools/instagram/get_conversation_messages.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { DEFAULT_MAX_ERROR_BODY_BYTES } from '@/lib/core/utils/stream-limits' +import { instagramGetConversationMessagesTool } from '@/tools/instagram/get_conversation_messages' +import { instagramGetMessageTool } from '@/tools/instagram/get_message' +import { INSTAGRAM_RESPONSE_MAX_BYTES } from '@/tools/instagram/utils' + +describe('instagramGetConversationMessagesTool', () => { + it('applies limit and cursor to the nested messages connection', () => { + const buildUrl = instagramGetConversationMessagesTool.request.url + if (typeof buildUrl !== 'function') throw new Error('Expected a dynamic request URL') + + const url = new URL( + buildUrl({ + accessToken: 'token', + conversationId: ' conversation-1 ', + limit: 7, + after: ' cursor-2 ', + }) + ) + + expect(url.pathname.startsWith('/v25.0/')).toBe(true) + expect(url.pathname.endsWith('/conversation-1')).toBe(true) + expect(url.searchParams.get('fields')).toBe( + 'messages.limit(7).after(cursor-2){id,created_time,is_unsupported}' + ) + }) + + it('maps documented message fields and nested pagination', async () => { + const result = await instagramGetConversationMessagesTool.transformResponse?.( + Response.json({ + messages: { + data: [ + { + id: 'message-1', + created_time: '2025-07-28T22:44:49+0000', + is_unsupported: true, + }, + { id: 2, created_time: '2025-07-28T21:20:58+0000', is_unsupported: false }, + { created_time: '2025-07-28T21:20:13+0000' }, + ], + paging: { + cursors: { after: 'next-message-cursor' }, + next: 'https://graph.instagram.com/next', + }, + }, + id: 'conversation-1', + }), + { accessToken: 'token', conversationId: ' conversation-1 ' } + ) + + expect(result).toEqual({ + success: true, + output: { + conversationId: 'conversation-1', + messages: [ + { + id: 'message-1', + createdTime: '2025-07-28T22:44:49+0000', + isUnsupported: true, + }, + { + id: '2', + createdTime: '2025-07-28T21:20:58+0000', + isUnsupported: false, + }, + ], + nextCursor: 'next-message-cursor', + }, + }) + expect(instagramGetConversationMessagesTool.outputs?.messages.type).toBe('array') + expect(instagramGetConversationMessagesTool.outputs?.messages.items?.properties).toHaveProperty( + 'isUnsupported' + ) + }) + + it('rejects oversized Graph response bodies', async () => { + const transform = instagramGetConversationMessagesTool.transformResponse + if (!transform) throw new Error('Expected a response transform') + + await expect( + transform( + Response.json({ + messages: { data: [] }, + padding: 'x'.repeat(INSTAGRAM_RESPONSE_MAX_BYTES + 1), + }), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + ).rejects.toThrow( + `Instagram conversation messages response exceeds maximum size of ${INSTAGRAM_RESPONSE_MAX_BYTES} bytes` + ) + }) + + it('allows success responses above the smaller Graph error-body cap', async () => { + const transform = instagramGetConversationMessagesTool.transformResponse + if (!transform) throw new Error('Expected a response transform') + + const result = await transform( + Response.json({ + id: 'conversation-1', + messages: { data: [] }, + padding: 'x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1), + }), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + + expect(result).toMatchObject({ + success: true, + output: { conversationId: 'conversation-1', messages: [] }, + }) + }) + + it('rejects malformed successful Graph responses', async () => { + const transform = instagramGetConversationMessagesTool.transformResponse + if (!transform) throw new Error('Expected a response transform') + + await expect( + transform(new Response('{not-json', { status: 200 }), { + accessToken: 'token', + conversationId: 'conversation-1', + }) + ).rejects.toThrow() + }) + + it('preserves Graph provider error metadata', async () => { + const result = await instagramGetConversationMessagesTool.transformResponse?.( + Response.json( + { + error: { + message: 'Bad request', + type: 'OAuthException', + code: 100, + error_subcode: 33, + fbtrace_id: 'trace-1', + }, + }, + { status: 400 } + ), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + + expect(result?.error).toBe( + 'Bad request (type OAuthException, code 100, subcode 33, trace trace-1)' + ) + }) + + it('caps Graph error bodies at the shared error-response limit', async () => { + const result = await instagramGetConversationMessagesTool.transformResponse?.( + Response.json( + { error: { message: 'x'.repeat(DEFAULT_MAX_ERROR_BODY_BYTES + 1) } }, + { status: 400, statusText: 'Bad Request' } + ), + { accessToken: 'token', conversationId: 'conversation-1' } + ) + + expect(result?.error).toBe('Bad Request') + }) +}) + +describe('instagramGetMessageTool', () => { + it('maps the documented sender username', async () => { + const result = await instagramGetMessageTool.transformResponse?.( + Response.json({ + id: 'message-1', + created_time: '2025-07-28T22:44:49+0000', + from: { id: 'sender-1', username: 'sender.username' }, + to: { data: [{ id: 'recipient-1', username: 'recipient.username' }] }, + message: 'Hello', + }) + ) + + expect(result?.output).toMatchObject({ + id: 'message-1', + fromId: 'sender-1', + fromUsername: 'sender.username', + toId: 'recipient-1', + message: 'Hello', + }) + }) +}) diff --git a/apps/sim/tools/instagram/get_conversation_messages.ts b/apps/sim/tools/instagram/get_conversation_messages.ts new file mode 100644 index 00000000000..11c4fd06f49 --- /dev/null +++ b/apps/sim/tools/instagram/get_conversation_messages.ts @@ -0,0 +1,136 @@ +import { INSTAGRAM_MESSAGE_REFERENCE_PROPERTIES } from '@/tools/instagram/output-properties' +import type { + InstagramGetConversationMessagesParams, + InstagramGetConversationMessagesResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + idString, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +function buildMessagesField(limit: number | undefined, after: string | undefined): string { + const cursor = after?.trim() + const pagination = cursor ? `.after(${cursor})` : '' + return `messages.limit(${clampGraphLimit(limit)})${pagination}{id,created_time,is_unsupported}` +} + +export const instagramGetConversationMessagesTool: ToolConfig< + InstagramGetConversationMessagesParams, + InstagramGetConversationMessagesResponse +> = { + id: 'instagram_get_conversation_messages', + name: 'Instagram Get Conversation Messages', + description: + 'List cursor-paginated message references; full details are available only for the 20 most recent messages', + 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', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of message references to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Nested messages pagination cursor', + }, + }, + + request: { + url: (params) => + graphUrl(`/${params.conversationId.trim()}`, { + fields: buildMessagesField(params.limit, params.after), + }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async ( + response, + params + ): Promise => { + if (!response.ok) { + return { + success: false, + output: { + conversationId: params?.conversationId?.trim() ?? '', + messages: [], + nextCursor: null, + }, + error: await readGraphError(response), + } + } + + const data = await readGraphJson<{ + id?: string | number + messages?: InstagramGraphPage> + }>(response, 'Instagram conversation messages response') + const items = Array.isArray(data.messages?.data) ? data.messages.data : [] + const messages = items.flatMap((item: Record) => { + const id = + typeof item.id === 'string' || typeof item.id === 'number' ? idString(item.id) : null + if (!id) return [] + + return [ + { + id, + createdTime: typeof item.created_time === 'string' ? item.created_time : null, + isUnsupported: item.is_unsupported === true, + }, + ] + }) + + return { + success: true, + output: { + conversationId: params?.conversationId?.trim() || idString(data.id) || '', + messages, + nextCursor: + data.messages?.paging?.next && typeof data.messages?.paging?.cursors?.after === 'string' + ? data.messages.paging.cursors.after + : null, + }, + } + }, + + outputs: { + conversationId: { type: 'string', description: 'Conversation id' }, + messages: { + type: 'array', + description: + 'Message references (id, createdTime). Use Get Message for sender, recipient, and text.', + items: { type: 'object', properties: INSTAGRAM_MESSAGE_REFERENCE_PROPERTIES }, + }, + nextCursor: { + type: 'string', + description: 'Nested messages pagination cursor', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/instagram/get_media.ts b/apps/sim/tools/instagram/get_media.ts new file mode 100644 index 00000000000..0b3fb472466 --- /dev/null +++ b/apps/sim/tools/instagram/get_media.ts @@ -0,0 +1,129 @@ +import { INSTAGRAM_CHILD_MEDIA_PROPERTIES } from '@/tools/instagram/output-properties' +import type { InstagramGetMediaParams, InstagramGetMediaResponse } from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + type InstagramGraphPage, + idString, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const MEDIA_FIELDS = + 'id,caption,media_type,media_product_type,media_url,permalink,timestamp,like_count,comments_count,children{id}' + +export const instagramGetMediaTool: ToolConfig = + { + id: 'instagram_get_media', + name: 'Instagram Get Media', + description: 'Get details for a specific Instagram media object', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + mediaId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram media id', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.mediaId.trim()}`, { fields: MEDIA_FIELDS }), + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { + id: null, + caption: null, + mediaType: null, + mediaProductType: null, + mediaUrl: null, + permalink: null, + timestamp: null, + likeCount: null, + commentsCount: null, + children: [], + }, + error: await readGraphError(response), + } + } + + const data = await readGraphJson<{ + id?: string | number + caption?: string + media_type?: string + media_product_type?: string + media_url?: string + permalink?: string + timestamp?: string + like_count?: number + comments_count?: number + children?: InstagramGraphPage<{ id?: unknown }> + }>(response, 'Instagram media response') + const children = Array.isArray(data.children?.data) + ? data.children.data.flatMap((child: { id?: unknown }) => { + const id = idString(child.id) + return id ? [{ 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: 'Instagram media URL when available; use Download Media to persist it', + 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: 'array', + description: 'Carousel child media IDs', + items: { type: 'object', properties: INSTAGRAM_CHILD_MEDIA_PROPERTIES }, + }, + }, + } 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..d39d1ae6ffa --- /dev/null +++ b/apps/sim/tools/instagram/get_media_insights.ts @@ -0,0 +1,97 @@ +import { INSTAGRAM_INSIGHT_PROPERTIES } from '@/tools/instagram/output-properties' +import type { + InstagramGetMediaInsightsParams, + InstagramGetMediaInsightsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + parseCommaSeparated, + readGraphError, + readGraphJson, +} 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 readGraphJson<{ data?: Record[] }>( + response, + 'Instagram media insights response' + ) + 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: 'array', + description: 'Media insight metrics', + items: { type: 'object', properties: INSTAGRAM_INSIGHT_PROPERTIES }, + }, + }, +} diff --git a/apps/sim/tools/instagram/get_message.ts b/apps/sim/tools/instagram/get_message.ts new file mode 100644 index 00000000000..e728da256db --- /dev/null +++ b/apps/sim/tools/instagram/get_message.ts @@ -0,0 +1,116 @@ +import type { + InstagramGetMessageParams, + InstagramGetMessageResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + type InstagramGraphPage, + idString, + readGraphError, + readGraphJson, +} 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 readGraphJson<{ + id?: string | number + created_time?: string + from?: { id?: string | number; username?: string } + to?: InstagramGraphPage<{ id?: string | number; username?: string }> + message?: string + }>(response, 'Instagram message response') + const from = data.from + const toData = data.to?.data + const toFirst = Array.isArray(toData) ? toData[0] : undefined + const id = idString(data.id) + if (!id) { + return { + success: false, + output: { + id: null, + createdTime: null, + fromId: null, + fromUsername: null, + toId: null, + message: null, + }, + error: 'Instagram message response did not include a message id', + } + } + + return { + success: true, + output: { + id, + 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' }, + 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..03205260cc1 --- /dev/null +++ b/apps/sim/tools/instagram/get_profile.ts @@ -0,0 +1,108 @@ +import type { + InstagramGetProfileParams, + InstagramGetProfileResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + idString, + readGraphError, + readGraphJson, +} 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 readGraphJson<{ + user_id?: string | number + id?: string | number + username?: string + name?: string + account_type?: string + profile_picture_url?: string + followers_count?: number + follows_count?: number + media_count?: number + }>(response, 'Instagram profile response') + + return { + success: true, + 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.test.ts b/apps/sim/tools/instagram/get_publishing_limit.test.ts new file mode 100644 index 00000000000..1c14b332c24 --- /dev/null +++ b/apps/sim/tools/instagram/get_publishing_limit.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { instagramGetPublishingLimitTool } from '@/tools/instagram/get_publishing_limit' + +describe('instagramGetPublishingLimitTool', () => { + it('maps and describes the structured quota config', async () => { + const result = await instagramGetPublishingLimitTool.transformResponse?.( + Response.json({ + data: [ + { + quota_usage: 12, + config: { quota_total: 100, quota_duration: 86_400 }, + }, + ], + }), + { accessToken: 'token' } + ) + + expect(result).toEqual({ + success: true, + output: { + quotaUsage: 12, + config: { quotaTotal: 100, quotaDuration: 86_400 }, + }, + }) + expect(instagramGetPublishingLimitTool.outputs?.config.properties).toMatchObject({ + quotaTotal: { type: 'number', nullable: true }, + quotaDuration: { type: 'number', nullable: true }, + }) + }) + + it('normalizes malformed quota values instead of leaking invalid output types', async () => { + const result = await instagramGetPublishingLimitTool.transformResponse?.( + Response.json({ + data: [{ quota_usage: '12', config: { quota_total: '100', quota_duration: null } }], + }), + { accessToken: 'token' } + ) + + expect(result).toEqual({ + success: true, + output: { + quotaUsage: null, + config: { quotaTotal: null, quotaDuration: null }, + }, + }) + }) +}) diff --git a/apps/sim/tools/instagram/get_publishing_limit.ts b/apps/sim/tools/instagram/get_publishing_limit.ts new file mode 100644 index 00000000000..9f1fc4432f3 --- /dev/null +++ b/apps/sim/tools/instagram/get_publishing_limit.ts @@ -0,0 +1,127 @@ +import type { + InstagramGetPublishingLimitParams, + InstagramGetPublishingLimitResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +function finiteNumberOrNull(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function parsePublishingLimitConfig(value: unknown): { + quotaTotal: number | null + quotaDuration: number | null +} | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + + const config = value as Record + return { + quotaTotal: finiteNumberOrNull(config.quota_total), + quotaDuration: finiteNumberOrNull(config.quota_duration), + } +} + +interface PublishingLimitData { + quota_usage?: number + config?: { + quota_total?: number + quota_duration?: number + } +} + +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 readGraphJson & PublishingLimitData>( + response, + 'Instagram publishing limit response' + ) + const first = Array.isArray(data.data) ? data.data[0] : data + + return { + success: true, + output: { + quotaUsage: finiteNumberOrNull(first?.quota_usage), + config: parsePublishingLimitConfig(first?.config), + }, + } + }, + + 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, + properties: { + quotaTotal: { + type: 'number', + description: 'Total publishes allowed in the quota window', + nullable: true, + }, + quotaDuration: { + type: 'number', + description: 'Quota window duration reported by Instagram', + nullable: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/instagram/hide_comment.ts b/apps/sim/tools/instagram/hide_comment.ts new file mode 100644 index 00000000000..7e7d96629d7 --- /dev/null +++ b/apps/sim/tools/instagram/hide_comment.ts @@ -0,0 +1,79 @@ +import type { + InstagramHideCommentParams, + InstagramHideCommentResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError, readGraphJson } 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 readGraphJson<{ success?: boolean }>( + response, + 'Instagram hide comment response' + ) + if (data.success !== true) { + return { + success: false, + output: { success: false }, + error: 'Instagram did not confirm that the comment visibility was updated', + } + } + + return { + success: true, + output: { success: true }, + } + }, + + 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..8a158afc6e7 --- /dev/null +++ b/apps/sim/tools/instagram/index.ts @@ -0,0 +1,25 @@ +export { instagramDeleteCommentTool } from '@/tools/instagram/delete_comment' +export { instagramDownloadMediaTool } from '@/tools/instagram/download_media' +export { instagramGetAccountInsightsTool } from '@/tools/instagram/get_account_insights' +export { instagramGetContainerStatusTool } from '@/tools/instagram/get_container_status' +export { instagramGetConversationMessagesTool } from '@/tools/instagram/get_conversation_messages' +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..e831975b3cc --- /dev/null +++ b/apps/sim/tools/instagram/list_comments.ts @@ -0,0 +1,125 @@ +import { INSTAGRAM_COMMENT_PROPERTIES } from '@/tools/instagram/output-properties' +import type { + InstagramListCommentsParams, + InstagramListCommentsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} 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,from,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 readGraphJson>>( + response, + 'Instagram comments response' + ) + const items = Array.isArray(data.data) ? data.data : [] + const comments = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + + const from = + item.from && typeof item.from === 'object' + ? (item.from as { username?: unknown }) + : undefined + + return [ + { + id, + text: typeof item.text === 'string' ? item.text : null, + username: + typeof from?.username === 'string' + ? from.username + : typeof item.username === 'string' + ? item.username + : null, + timestamp: typeof item.timestamp === 'string' ? item.timestamp : null, + likeCount: typeof item.like_count === 'number' ? item.like_count : null, + hidden: typeof item.hidden === 'boolean' ? item.hidden : null, + }, + ] + }) + + return { + success: true, + output: { + comments, + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + comments: { + type: 'array', + description: 'Comments on the media object', + items: { type: 'object', properties: INSTAGRAM_COMMENT_PROPERTIES }, + }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/list_conversations.ts b/apps/sim/tools/instagram/list_conversations.ts new file mode 100644 index 00000000000..f24260a5630 --- /dev/null +++ b/apps/sim/tools/instagram/list_conversations.ts @@ -0,0 +1,115 @@ +import { INSTAGRAM_CONVERSATION_PROPERTIES } from '@/tools/instagram/output-properties' +import type { + InstagramListConversationsParams, + InstagramListConversationsResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} 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 readGraphJson>>( + response, + 'Instagram conversations response' + ) + const items = Array.isArray(data.data) ? data.data : [] + const conversations = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + return [ + { + id, + updatedTime: typeof item.updated_time === 'string' ? item.updated_time : null, + }, + ] + }) + + return { + success: true, + output: { + conversations, + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + conversations: { + type: 'array', + description: 'Instagram Direct conversations from this page', + items: { type: 'object', properties: INSTAGRAM_CONVERSATION_PROPERTIES }, + }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/list_media.ts b/apps/sim/tools/instagram/list_media.ts new file mode 100644 index 00000000000..d655c88b758 --- /dev/null +++ b/apps/sim/tools/instagram/list_media.ts @@ -0,0 +1,125 @@ +import { INSTAGRAM_MEDIA_PROPERTIES } from '@/tools/instagram/output-properties' +import type { InstagramListMediaParams, InstagramListMediaResponse } from '@/tools/instagram/types' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +const MEDIA_FIELDS = + 'id,caption,media_type,media_product_type,media_url,permalink,timestamp,like_count,comments_count' + +export const instagramListMediaTool: ToolConfig< + InstagramListMediaParams, + InstagramListMediaResponse +> = { + id: 'instagram_list_media', + name: 'Instagram List Media', + description: 'List recent media on the Instagram professional account', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of media items to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous list_media response', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/media` : '/me/media' + return graphUrl(path, { + fields: MEDIA_FIELDS, + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { media: [], nextCursor: null }, + error: await readGraphError(response), + } + } + + const data = await readGraphJson>>( + response, + 'Instagram media list response' + ) + const items = Array.isArray(data.data) ? data.data : [] + const media = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + + return [ + { + id, + caption: typeof item.caption === 'string' ? item.caption : null, + mediaType: typeof item.media_type === 'string' ? item.media_type : null, + mediaProductType: + typeof item.media_product_type === 'string' ? item.media_product_type : null, + mediaUrl: typeof item.media_url === 'string' ? item.media_url : null, + permalink: typeof item.permalink === 'string' ? item.permalink : null, + timestamp: typeof item.timestamp === 'string' ? item.timestamp : null, + likeCount: typeof item.like_count === 'number' ? item.like_count : null, + commentsCount: typeof item.comments_count === 'number' ? item.comments_count : null, + }, + ] + }) + + return { + success: true, + output: { + media, + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + media: { + type: 'array', + description: 'Media objects from this page', + items: { type: 'object', properties: INSTAGRAM_MEDIA_PROPERTIES }, + }, + 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..f5b9fe4981e --- /dev/null +++ b/apps/sim/tools/instagram/list_stories.ts @@ -0,0 +1,115 @@ +import { INSTAGRAM_STORY_PROPERTIES } from '@/tools/instagram/output-properties' +import type { + InstagramListStoriesParams, + InstagramListStoriesResponse, +} from '@/tools/instagram/types' +import { + bearerHeaders, + clampGraphLimit, + graphUrl, + type InstagramGraphPage, + readGraphError, + readGraphJson, +} 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)', + }, + limit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Max number of active stories to return (default 25, max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor from a previous List Stories response', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/stories` : '/me/stories' + return graphUrl(path, { + fields: 'id,media_type,media_url,timestamp', + limit: String(clampGraphLimit(params.limit)), + after: params.after?.trim() || undefined, + }) + }, + method: 'GET', + headers: (params) => bearerHeaders(params.accessToken), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { stories: [], nextCursor: null }, + error: await readGraphError(response), + } + } + + const data = await readGraphJson>>( + response, + 'Instagram stories response' + ) + const items = Array.isArray(data.data) ? data.data : [] + const stories = items.flatMap((item: Record) => { + const id = item.id == null || item.id === '' ? null : String(item.id) + if (!id) return [] + + return [ + { + id, + mediaType: typeof item.media_type === 'string' ? item.media_type : null, + mediaUrl: typeof item.media_url === 'string' ? item.media_url : null, + timestamp: typeof item.timestamp === 'string' ? item.timestamp : null, + }, + ] + }) + + return { + success: true, + output: { + stories, + nextCursor: data.paging?.next ? (data.paging?.cursors?.after ?? null) : null, + }, + } + }, + + outputs: { + stories: { + type: 'array', + description: 'Active stories from this page', + items: { type: 'object', properties: INSTAGRAM_STORY_PROPERTIES }, + }, + nextCursor: { type: 'string', description: 'Pagination cursor', optional: true }, + }, +} diff --git a/apps/sim/tools/instagram/output-properties.ts b/apps/sim/tools/instagram/output-properties.ts new file mode 100644 index 00000000000..62be3df0a88 --- /dev/null +++ b/apps/sim/tools/instagram/output-properties.ts @@ -0,0 +1,75 @@ +import type { OutputProperty } from '@/tools/types' + +export const INSTAGRAM_MEDIA_PROPERTIES = { + id: { type: 'string', description: 'Instagram media ID' }, + caption: { type: 'string', description: 'Caption text', nullable: true }, + mediaType: { type: 'string', description: 'IMAGE, VIDEO, or CAROUSEL_ALBUM', nullable: true }, + mediaProductType: { + type: 'string', + description: 'Feed, Reels, or Stories product type', + nullable: true, + }, + mediaUrl: { + type: 'string', + description: 'Instagram media URL when available', + nullable: true, + }, + permalink: { type: 'string', description: 'Permalink to the media', nullable: true }, + timestamp: { type: 'string', description: 'ISO timestamp', nullable: true }, + likeCount: { type: 'number', description: 'Like count', nullable: true }, + commentsCount: { type: 'number', description: 'Comment count', nullable: true }, +} satisfies Record + +export const INSTAGRAM_STORY_PROPERTIES = { + id: { type: 'string', description: 'Instagram story ID' }, + mediaType: { type: 'string', description: 'IMAGE or VIDEO', nullable: true }, + mediaUrl: { + type: 'string', + description: 'Instagram story media URL when available', + nullable: true, + }, + timestamp: { type: 'string', description: 'ISO timestamp', nullable: true }, +} satisfies Record + +export const INSTAGRAM_COMMENT_PROPERTIES = { + id: { type: 'string', description: 'Instagram comment ID' }, + text: { type: 'string', description: 'Comment text', nullable: true }, + username: { type: 'string', description: 'Comment author username', nullable: true }, + timestamp: { type: 'string', description: 'ISO timestamp', nullable: true }, + likeCount: { type: 'number', description: 'Like count', nullable: true }, + hidden: { type: 'boolean', description: 'Whether the comment is hidden', nullable: true }, +} satisfies Record + +export const INSTAGRAM_CONVERSATION_PROPERTIES = { + id: { type: 'string', description: 'Instagram conversation ID' }, + updatedTime: { type: 'string', description: 'Last updated timestamp', nullable: true }, +} satisfies Record + +export const INSTAGRAM_MESSAGE_REFERENCE_PROPERTIES = { + id: { type: 'string', description: 'Instagram message ID' }, + createdTime: { type: 'string', description: 'Created timestamp', nullable: true }, + isUnsupported: { + type: 'boolean', + description: 'Whether this message type is unsupported by the API', + }, +} satisfies Record + +export const INSTAGRAM_INSIGHT_PROPERTIES = { + name: { type: 'string', description: 'Metric name', nullable: true }, + period: { type: 'string', description: 'Aggregation period', nullable: true }, + title: { type: 'string', description: 'Human-readable metric title', nullable: true }, + description: { type: 'string', description: 'Metric description', nullable: true }, + values: { + type: 'json', + description: 'Metric values; shape varies by metric and requested breakdown', + }, + totalValue: { + type: 'json', + description: 'Aggregate metric value; shape varies by metric and breakdown', + nullable: true, + }, +} satisfies Record + +export const INSTAGRAM_CHILD_MEDIA_PROPERTIES = { + id: { type: 'string', description: 'Carousel child media ID' }, +} satisfies Record diff --git a/apps/sim/tools/instagram/private_reply.ts b/apps/sim/tools/instagram/private_reply.ts new file mode 100644 index 00000000000..43a27004db0 --- /dev/null +++ b/apps/sim/tools/instagram/private_reply.ts @@ -0,0 +1,108 @@ +import type { + InstagramPrivateReplyParams, + InstagramPrivateReplyResponse, +} from '@/tools/instagram/types' +import { + graphUrl, + idString, + jsonBearerHeaders, + readGraphError, + readGraphJson, +} 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 the one allowed initial private reply within 7 days of a comment; follow-ups require a recipient response', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to privately reply to', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Private reply text', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => jsonBearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { comment_id: params.commentId.trim() }, + message: { text: params.message }, + }), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: await readGraphError(response), + } + } + + const data = await readGraphJson<{ + message_id?: string | number + recipient_id?: string | number + }>(response, 'Instagram private reply response') + const messageId = idString(data.message_id) + const recipientId = idString(data.recipient_id) + if (!messageId || !recipientId) { + return { + success: false, + output: { messageId: null, recipientId: null }, + error: 'Instagram private reply response did not include message and recipient ids', + } + } + + return { + success: true, + output: { + messageId, + recipientId, + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'Sent message id' }, + recipientId: { + type: 'string', + description: 'Instagram-scoped recipient id', + }, + }, +} diff --git a/apps/sim/tools/instagram/publish_carousel.ts b/apps/sim/tools/instagram/publish_carousel.ts new file mode 100644 index 00000000000..8aec385dc7e --- /dev/null +++ b/apps/sim/tools/instagram/publish_carousel.ts @@ -0,0 +1,68 @@ +import { + type InstagramPublishCarouselParams, + type InstagramPublishResponse, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishCarouselTool: ToolConfig< + InstagramPublishCarouselParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_carousel', + name: 'Instagram Publish Carousel', + description: 'Publish a carousel of 2-10 images/videos from uploaded files or public HTTPS URLs', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + media: { + type: 'file[]', + required: true, + visibility: 'user-or-llm', + description: + '2-10 media files, or a comma-separated public URL string (prefix videos with video:)', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Carousel caption', + }, + }, + + request: { + url: '/api/tools/instagram/publish-carousel', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishCarouselParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + media: params.media, + caption: params.caption, + }), + }, + + transformResponse: createPublishTransform('Failed to publish carousel'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_image.ts b/apps/sim/tools/instagram/publish_image.ts new file mode 100644 index 00000000000..08ce40a3568 --- /dev/null +++ b/apps/sim/tools/instagram/publish_image.ts @@ -0,0 +1,82 @@ +import { + type InstagramPublishImageParams, + type InstagramPublishResponse, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishImageTool: ToolConfig< + InstagramPublishImageParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_image', + name: 'Instagram Publish Image', + description: + 'Create and publish a single JPEG image post from an uploaded file or public HTTPS URL (polls until the container is ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + image: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'JPEG image file or public HTTPS URL (Meta will download it)', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post caption (max 2200 characters)', + }, + altText: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Accessibility alt text for the image', + }, + isAiGenerated: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Mark the post as AI-generated', + }, + }, + + request: { + url: '/api/tools/instagram/publish-image', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishImageParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + image: params.image, + caption: params.caption, + altText: params.altText, + isAiGenerated: params.isAiGenerated, + }), + }, + + transformResponse: createPublishTransform('Failed to publish image'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_reel.ts b/apps/sim/tools/instagram/publish_reel.ts new file mode 100644 index 00000000000..b7b22c17bb7 --- /dev/null +++ b/apps/sim/tools/instagram/publish_reel.ts @@ -0,0 +1,89 @@ +import { + type InstagramPublishReelParams, + type InstagramPublishResponse, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishReelTool: ToolConfig< + InstagramPublishReelParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_reel', + name: 'Instagram Publish Reel', + description: + 'Create and publish a Reel from an uploaded video file or public HTTPS URL (polls until ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + video: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'Reel video file or public HTTPS URL', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Reel caption', + }, + cover: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + shareToFeed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Also share the Reel to the main feed', + }, + thumbOffset: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Frame offset in milliseconds for the cover thumbnail', + }, + }, + + request: { + url: '/api/tools/instagram/publish-reel', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishReelParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + video: params.video, + cover: params.cover, + caption: params.caption, + shareToFeed: params.shareToFeed, + thumbOffset: params.thumbOffset, + }), + }, + + transformResponse: createPublishTransform('Failed to publish reel'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_story.ts b/apps/sim/tools/instagram/publish_story.ts new file mode 100644 index 00000000000..2bec29f3af5 --- /dev/null +++ b/apps/sim/tools/instagram/publish_story.ts @@ -0,0 +1,61 @@ +import { + type InstagramPublishResponse, + type InstagramPublishStoryParams, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishStoryTool: ToolConfig< + InstagramPublishStoryParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_story', + name: 'Instagram Publish Story', + description: + 'Publish an image or video story for an Instagram professional account from an uploaded file or public HTTPS URL', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + media: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'JPEG image or MP4/MOV video file, or a public HTTPS URL', + }, + }, + + request: { + url: '/api/tools/instagram/publish-story', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishStoryParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + media: params.media, + }), + }, + + transformResponse: createPublishTransform('Failed to publish story'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/publish_video.ts b/apps/sim/tools/instagram/publish_video.ts new file mode 100644 index 00000000000..2763c0a9240 --- /dev/null +++ b/apps/sim/tools/instagram/publish_video.ts @@ -0,0 +1,75 @@ +import { + type InstagramPublishResponse, + type InstagramPublishVideoParams, + PUBLISH_OUTPUTS, +} from '@/tools/instagram/types' +import { createPublishTransform } from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramPublishVideoTool: ToolConfig< + InstagramPublishVideoParams, + InstagramPublishResponse +> = { + id: 'instagram_publish_video', + name: 'Instagram Publish Video', + description: + 'Create and publish a feed video from an uploaded file or public HTTPS URL (published as a Reel shared to the feed; polls until ready)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + video: { + type: 'file', + required: true, + visibility: 'user-or-llm', + description: 'Video file or public HTTPS URL', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Post caption', + }, + cover: { + type: 'file', + required: false, + visibility: 'user-or-llm', + description: 'Optional JPEG cover image file or public HTTPS URL', + }, + }, + + request: { + url: '/api/tools/instagram/publish-video', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params: InstagramPublishVideoParams) => ({ + accessToken: params.accessToken, + igUserId: params.igUserId, + video: params.video, + cover: params.cover, + caption: params.caption, + }), + }, + + transformResponse: createPublishTransform('Failed to publish video'), + + outputs: PUBLISH_OUTPUTS, +} diff --git a/apps/sim/tools/instagram/reply_to_comment.ts b/apps/sim/tools/instagram/reply_to_comment.ts new file mode 100644 index 00000000000..fea5dc324a8 --- /dev/null +++ b/apps/sim/tools/instagram/reply_to_comment.ts @@ -0,0 +1,87 @@ +import type { + InstagramReplyToCommentParams, + InstagramReplyToCommentResponse, +} from '@/tools/instagram/types' +import { + graphUrl, + idString, + jsonBearerHeaders, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramReplyToCommentTool: ToolConfig< + InstagramReplyToCommentParams, + InstagramReplyToCommentResponse +> = { + id: 'instagram_reply_to_comment', + name: 'Instagram Reply to Comment', + description: 'Reply to a comment on Instagram media', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + commentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comment id to reply to', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Reply text', + }, + }, + + request: { + url: (params) => graphUrl(`/${params.commentId.trim()}/replies`), + method: 'POST', + headers: (params) => jsonBearerHeaders(params.accessToken), + body: (params) => ({ message: params.message }), + }, + + transformResponse: async (response): Promise => { + if (!response.ok) { + return { + success: false, + output: { id: null }, + error: await readGraphError(response), + } + } + + const data = await readGraphJson<{ id?: string | number }>( + response, + 'Instagram reply comment response' + ) + const id = idString(data.id) + if (!id) { + return { + success: false, + output: { id: null }, + error: 'Instagram reply response did not include a comment id', + } + } + + return { + success: true, + output: { id }, + } + }, + + outputs: { + id: { type: 'string', description: 'Created reply comment id' }, + }, +} diff --git a/apps/sim/tools/instagram/send_text_message.ts b/apps/sim/tools/instagram/send_text_message.ts new file mode 100644 index 00000000000..bf96edb451d --- /dev/null +++ b/apps/sim/tools/instagram/send_text_message.ts @@ -0,0 +1,105 @@ +import type { + InstagramSendTextMessageParams, + InstagramSendTextMessageResponse, +} from '@/tools/instagram/types' +import { + graphUrl, + idString, + jsonBearerHeaders, + readGraphError, + readGraphJson, +} from '@/tools/instagram/utils' +import type { ToolConfig } from '@/tools/types' + +export const instagramSendTextMessageTool: ToolConfig< + InstagramSendTextMessageParams, + InstagramSendTextMessageResponse +> = { + id: 'instagram_send_text_message', + name: 'Instagram Send Text Message', + description: + 'Send a text Direct message. The recipient must have messaged the account first (24h window).', + version: '1.0.0', + + oauth: { + required: true, + provider: 'instagram', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Access token for Instagram API', + }, + igUserId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Instagram professional account user id (defaults to /me)', + }, + recipientId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Instagram-scoped user id (IGSID) of the recipient', + }, + message: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Message text (max 1000 bytes UTF-8)', + }, + }, + + request: { + url: (params) => { + const path = params.igUserId?.trim() ? `/${params.igUserId.trim()}/messages` : '/me/messages' + return graphUrl(path) + }, + method: 'POST', + headers: (params) => jsonBearerHeaders(params.accessToken), + body: (params) => ({ + recipient: { id: params.recipientId.trim() }, + message: { text: params.message }, + }), + }, + + transformResponse: async (response, params): Promise => { + if (!response.ok) { + return { + success: false, + output: { messageId: null, recipientId: params?.recipientId?.trim() ?? null }, + error: await readGraphError(response), + } + } + + const data = await readGraphJson<{ + message_id?: string | number + recipient_id?: string | number + }>(response, 'Instagram send message response') + const messageId = idString(data.message_id) + const recipientId = idString(data.recipient_id) ?? params?.recipientId?.trim() ?? null + if (!messageId || !recipientId) { + return { + success: false, + output: { messageId: null, recipientId }, + error: 'Instagram send message response did not include the required ids', + } + } + + return { + success: true, + output: { + messageId, + recipientId, + }, + } + }, + + outputs: { + messageId: { type: 'string', description: 'Sent message id' }, + recipientId: { type: 'string', description: 'Recipient id' }, + }, +} diff --git a/apps/sim/tools/instagram/set_comments_enabled.ts b/apps/sim/tools/instagram/set_comments_enabled.ts new file mode 100644 index 00000000000..9ceb6a7d615 --- /dev/null +++ b/apps/sim/tools/instagram/set_comments_enabled.ts @@ -0,0 +1,80 @@ +import type { + InstagramSetCommentsEnabledParams, + InstagramSetCommentsEnabledResponse, +} from '@/tools/instagram/types' +import { bearerHeaders, graphUrl, readGraphError, readGraphJson } 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 readGraphJson<{ success?: boolean }>( + response, + 'Instagram comment setting response' + ) + if (data.success !== true) { + return { + success: false, + output: { success: false }, + error: 'Instagram did not confirm that the comment setting was updated', + } + } + + return { + success: true, + output: { success: true }, + } + }, + + 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..09fe29d33a2 --- /dev/null +++ b/apps/sim/tools/instagram/types.ts @@ -0,0 +1,385 @@ +import type { RawFileInput } from '@/lib/uploads/utils/file-schemas' +import type { UserFile } from '@/executor/types' +import type { OutputProperty, ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +/** Shared outputs returned by every Instagram publishing operation. */ +export const PUBLISH_OUTPUTS = { + containerId: { type: 'string', description: 'Media container ID', optional: true }, + mediaId: { type: 'string', description: 'Published media ID', optional: true }, + statusCode: { type: 'string', description: 'Final container status', optional: true }, +} satisfies Record + +export interface InstagramAccessParams { + accessToken: string + igUserId?: string +} + +export interface InstagramGetProfileParams { + accessToken: string +} + +export interface InstagramGetProfileResponse extends ToolResponse { + output: { + userId: string | null + id: string | null + username: string | null + name: string | null + accountType: string | null + profilePictureUrl: string | null + followersCount: number | null + followsCount: number | null + mediaCount: number | null + } +} + +export interface InstagramListMediaParams extends InstagramAccessParams { + limit?: number + after?: string +} + +export interface InstagramListMediaResponse extends ToolResponse { + output: { + media: Array<{ + id: string + caption: string | null + mediaType: string | null + mediaProductType: string | null + mediaUrl: string | null + permalink: string | null + timestamp: string | null + likeCount: number | null + commentsCount: number | null + }> + nextCursor: string | null + } +} + +export interface InstagramGetMediaParams { + accessToken: string + mediaId: string +} + +export interface InstagramGetMediaResponse extends ToolResponse { + output: { + id: string | null + caption: string | null + mediaType: string | null + mediaProductType: string | null + mediaUrl: string | null + permalink: string | null + timestamp: string | null + likeCount: number | null + commentsCount: number | null + children: Array<{ id: string }> + } +} + +export interface InstagramDownloadMediaParams { + accessToken: string + mediaId: string + filename?: string + _context?: WorkflowToolExecutionContext +} + +export interface InstagramDownloadMediaResponse extends ToolResponse { + output: { + files: UserFile[] + mediaId: string + mediaType: string | null + downloadedCount: number + } +} + +export interface InstagramListStoriesParams extends InstagramAccessParams { + limit?: number + after?: string +} + +export interface InstagramListStoriesResponse extends ToolResponse { + output: { + stories: Array<{ + id: string + mediaType: string | null + mediaUrl: string | null + timestamp: string | null + }> + nextCursor: string | null + } +} + +/** Uploaded UserFile object, or a public HTTPS URL string (advanced paste / legacy). */ +export type InstagramMediaInput = RawFileInput | 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: RawFileInput | RawFileInput[] | string + caption?: string +} + +export interface InstagramPublishResponse extends ToolResponse { + output: { + containerId: string | null + mediaId: string | null + statusCode: string | null + } +} + +export interface InstagramGetContainerStatusParams { + accessToken: string + containerId: string +} + +export interface InstagramGetContainerStatusResponse extends ToolResponse { + output: { + containerId: string + statusCode: string | null + status: string | null + } +} + +export interface InstagramGetPublishingLimitParams extends InstagramAccessParams {} + +export interface InstagramGetPublishingLimitResponse extends ToolResponse { + output: { + quotaUsage: number | null + config: { + quotaTotal: number | null + quotaDuration: number | null + } | null + } +} + +export interface InstagramListCommentsParams { + accessToken: string + mediaId: string + limit?: number + after?: string +} + +export interface InstagramListCommentsResponse extends ToolResponse { + output: { + comments: Array<{ + id: string + text: string | null + username: string | null + timestamp: string | null + likeCount: number | null + hidden: boolean | null + }> + nextCursor: string | null + } +} + +export interface InstagramReplyToCommentParams { + accessToken: string + commentId: string + message: string +} + +export interface InstagramReplyToCommentResponse extends ToolResponse { + output: { + id: string | null + } +} + +export interface InstagramHideCommentParams { + accessToken: string + commentId: string + hide: boolean +} + +export interface InstagramHideCommentResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramDeleteCommentParams { + accessToken: string + commentId: string +} + +export interface InstagramDeleteCommentResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramSetCommentsEnabledParams { + accessToken: string + mediaId: string + commentEnabled: boolean +} + +export interface InstagramSetCommentsEnabledResponse extends ToolResponse { + output: { + success: boolean + } +} + +export interface InstagramPrivateReplyParams extends InstagramAccessParams { + commentId: string + message: string +} + +export interface InstagramPrivateReplyResponse extends ToolResponse { + output: { + messageId: string | null + 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 + limit?: number + after?: string +} + +export interface InstagramGetConversationMessagesResponse extends ToolResponse { + output: { + conversationId: string + messages: Array<{ + id: string + createdTime: string | null + isUnsupported: boolean + }> + nextCursor: 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 type InstagramAccountInsightsPeriod = 'day' | 'lifetime' +export type InstagramAccountInsightsMetricType = 'time_series' | 'total_value' +export type InstagramAccountInsightsTimeframe = 'this_week' | 'this_month' + +export interface InstagramGetAccountInsightsParams extends InstagramAccessParams { + metrics: string + period: InstagramAccountInsightsPeriod + since?: string + until?: string + metricType?: InstagramAccountInsightsMetricType + breakdown?: string + timeframe?: InstagramAccountInsightsTimeframe +} + +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 + | InstagramDownloadMediaResponse + | InstagramListStoriesResponse + | InstagramPublishResponse + | InstagramGetContainerStatusResponse + | InstagramGetPublishingLimitResponse + | InstagramListCommentsResponse + | InstagramReplyToCommentResponse + | InstagramHideCommentResponse + | InstagramDeleteCommentResponse + | InstagramSetCommentsEnabledResponse + | InstagramPrivateReplyResponse + | InstagramListConversationsResponse + | InstagramGetConversationMessagesResponse + | InstagramGetMessageResponse + | InstagramSendTextMessageResponse + | InstagramGetAccountInsightsResponse + | InstagramGetMediaInsightsResponse diff --git a/apps/sim/tools/instagram/utils.test.ts b/apps/sim/tools/instagram/utils.test.ts new file mode 100644 index 00000000000..8c6b1bc6e0a --- /dev/null +++ b/apps/sim/tools/instagram/utils.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createPublishTransform, INSTAGRAM_RESPONSE_MAX_BYTES } from '@/tools/instagram/utils' + +const FALLBACK_OUTPUT = { + containerId: null, + mediaId: null, + statusCode: null, +} + +const SUCCESS_OUTPUT = { + containerId: 'container-1', + mediaId: 'media-1', + statusCode: 'FINISHED', +} + +describe('createPublishTransform', () => { + const transform = createPublishTransform('Failed to publish media') + + it('returns a validated successful publish response', async () => { + const result = await transform( + Response.json({ + success: true, + output: SUCCESS_OUTPUT, + }) + ) + + expect(result).toEqual({ success: true, output: SUCCESS_OUTPUT }) + }) + + it('preserves a structured failure from the publish route', async () => { + const result = await transform( + Response.json( + { + success: false, + error: 'Container processing failed', + output: FALLBACK_OUTPUT, + }, + { status: 422 } + ) + ) + + expect(result).toEqual({ + success: false, + output: FALLBACK_OUTPUT, + error: 'Container processing failed', + }) + }) + + it('returns failure for an empty successful HTTP response', async () => { + const result = await transform(new Response('', { status: 200 })) + + expect(result).toMatchObject({ success: false, output: FALLBACK_OUTPUT }) + }) + + it('returns failure for malformed JSON in a successful HTTP response', async () => { + const result = await transform(new Response('{not-json', { status: 200 })) + + expect(result).toMatchObject({ success: false, output: FALLBACK_OUTPUT }) + }) + + it.each([ + { name: 'a missing success discriminator', body: { output: SUCCESS_OUTPUT } }, + { name: 'a missing output', body: { success: true } }, + { + name: 'null identifiers', + body: { success: true, output: FALLBACK_OUTPUT }, + }, + { + name: 'an incomplete output', + body: { + success: true, + output: { containerId: 'container-1', mediaId: 'media-1' }, + }, + }, + ])('returns failure for $name', async ({ body }) => { + const result = await transform(Response.json(body)) + + expect(result).toEqual({ + success: false, + output: FALLBACK_OUTPUT, + error: 'Failed to publish media: invalid success response', + }) + }) + + it('returns failure when the bounded response reader rejects an oversized body', async () => { + const result = await transform( + new Response('x'.repeat(INSTAGRAM_RESPONSE_MAX_BYTES + 1), { status: 200 }) + ) + + expect(result).toMatchObject({ success: false, output: FALLBACK_OUTPUT }) + expect(result.error).toContain( + `Instagram publish response exceeds maximum size of ${INSTAGRAM_RESPONSE_MAX_BYTES} bytes` + ) + }) + + it('returns failure when the response stream cannot be read', async () => { + const body = new ReadableStream({ + start(controller) { + controller.error(new Error('response stream failed')) + }, + }) + + const result = await transform(new Response(body, { status: 200 })) + + expect(result).toEqual({ + success: false, + output: FALLBACK_OUTPUT, + error: 'response stream failed', + }) + }) +}) diff --git a/apps/sim/tools/instagram/utils.ts b/apps/sim/tools/instagram/utils.ts new file mode 100644 index 00000000000..6e4a7777672 --- /dev/null +++ b/apps/sim/tools/instagram/utils.ts @@ -0,0 +1,388 @@ +import { getErrorMessage, toError } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { + DEFAULT_MAX_ERROR_BODY_BYTES, + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { INSTAGRAM_GRAPH_BASE } from '@/lib/integrations/instagram/constants' +import type { InstagramPublishResponse } from '@/tools/instagram/types' + +export const INSTAGRAM_RESPONSE_MAX_BYTES = 2 * 1024 * 1024 + +export interface InstagramGraphPaging { + cursors?: { after?: string } + next?: string +} + +export interface InstagramGraphPage { + data?: T[] + paging?: InstagramGraphPaging +} + +export async function readGraphJson( + response: Response, + label: string, + signal?: AbortSignal +): Promise { + return readResponseJsonWithLimit(response, { + maxBytes: INSTAGRAM_RESPONSE_MAX_BYTES, + label, + signal, + }) +} + +export function bearerHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + } +} + +/** For the messaging endpoints, which take a JSON body (publish endpoints are form-encoded). */ +export function jsonBearerHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + } +} + +export function graphUrl(path: string, query?: Record): string { + const url = new URL( + path.startsWith('http') + ? path + : `${INSTAGRAM_GRAPH_BASE}${path.startsWith('/') ? path : `/${path}`}` + ) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== '') { + url.searchParams.set(key, value) + } + } + } + return url.toString() +} + +/** + * Graph may serialize IDs as strings or numbers. Normalize to a string (or + * null) so downstream tools can safely call .trim() on wired ID outputs. + */ +export function idString(value: unknown): string | null { + if (value == null || value === '') return null + return String(value) +} + +interface InstagramGraphErrorBody { + error?: { + message?: string + type?: string + code?: number + error_subcode?: number + fbtrace_id?: string + } +} + +export async function readGraphError(response: Response): Promise { + const text = await readResponseTextWithLimit(response, { + maxBytes: DEFAULT_MAX_ERROR_BODY_BYTES, + label: 'Instagram Graph error response', + }).catch(() => '') + + try { + const graphError = (JSON.parse(text) as InstagramGraphErrorBody).error + if (graphError?.message) { + const diagnostics = [ + graphError.type ? `type ${graphError.type}` : null, + graphError.code !== undefined ? `code ${graphError.code}` : null, + graphError.error_subcode !== undefined ? `subcode ${graphError.error_subcode}` : null, + graphError.fbtrace_id ? `trace ${graphError.fbtrace_id}` : null, + ].filter((value): value is string => value !== null) + + return diagnostics.length > 0 + ? `${graphError.message} (${diagnostics.join(', ')})` + : graphError.message + } + } catch { + return text || response.statusText + } + + return text || response.statusText +} + +export async function resolveIgUserId( + accessToken: string, + igUserId?: string, + signal?: AbortSignal +): Promise { + if (igUserId && igUserId.trim().length > 0) { + return igUserId.trim() + } + + const response = await fetch(graphUrl('/me', { fields: 'user_id' }), { + headers: bearerHeaders(accessToken), + signal, + }) + + if (!response.ok) { + throw new Error(`Failed to resolve Instagram user id: ${await readGraphError(response)}`) + } + + const data = await readGraphJson<{ user_id?: string | number }>( + response, + 'Instagram user response', + signal + ) + if (data.user_id == null || data.user_id === '') { + throw new Error('Instagram /me response did not include a user_id') + } + return String(data.user_id) +} + +export type ContainerStatusCode = 'EXPIRED' | 'ERROR' | 'FINISHED' | 'IN_PROGRESS' | 'PUBLISHED' + +export async function getContainerStatus( + accessToken: string, + containerId: string, + signal?: AbortSignal +): Promise<{ statusCode: ContainerStatusCode | null; status: string | null }> { + const response = await fetch(graphUrl(`/${containerId}`, { fields: 'status_code,status' }), { + headers: bearerHeaders(accessToken), + signal, + }) + + if (!response.ok) { + throw new Error(`Failed to get container status: ${await readGraphError(response)}`) + } + + const data = await readGraphJson<{ status_code?: string; status?: string }>( + response, + 'Instagram container status response', + signal + ) + return { + statusCode: (data.status_code as ContainerStatusCode | undefined) ?? null, + status: data.status ?? null, + } +} + +const POLL_INTERVAL_MS = 60_000 +const POLL_MAX_ATTEMPTS = 6 + +async function waitForNextPoll(signal?: AbortSignal): Promise { + if (!signal) { + await sleep(POLL_INTERVAL_MS) + return + } + + if (signal.aborted) { + throw toError(signal.reason ?? new Error('Instagram publishing was cancelled')) + } + + let abortHandler: (() => void) | undefined + const aborted = new Promise((_, reject) => { + abortHandler = () => + reject(toError(signal.reason ?? new Error('Instagram publishing was cancelled'))) + signal.addEventListener('abort', abortHandler, { once: true }) + }) + + try { + await Promise.race([sleep(POLL_INTERVAL_MS), aborted]) + } finally { + if (abortHandler) signal.removeEventListener('abort', abortHandler) + } +} + +export async function waitForContainerReady( + accessToken: string, + containerId: string, + signal?: AbortSignal +): Promise<{ statusCode: ContainerStatusCode; status: string | null }> { + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) { + const { statusCode, status } = await getContainerStatus(accessToken, containerId, signal) + + 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 waitForNextPoll(signal) + } + } + + 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, + signal?: AbortSignal +): 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(), + signal, + }) +} + +export async function createMediaContainer( + accessToken: string, + igUserId: string, + body: Record, + signal?: AbortSignal +): Promise { + const response = await postGraphForm(accessToken, `/${igUserId}/media`, body, signal) + + if (!response.ok) { + throw new Error(`Failed to create media container: ${await readGraphError(response)}`) + } + + const data = await readGraphJson<{ id?: string | number }>( + response, + 'Instagram create container response', + signal + ) + const id = idString(data.id) + if (!id) { + throw new Error('Create media container response missing id') + } + return id +} + +export async function publishMediaContainer( + accessToken: string, + igUserId: string, + creationId: string, + signal?: AbortSignal +): Promise { + const response = await postGraphForm( + accessToken, + `/${igUserId}/media_publish`, + { + creation_id: creationId, + }, + signal + ) + + if (!response.ok) { + throw new Error(`Failed to publish media: ${await readGraphError(response)}`) + } + + const data = await readGraphJson<{ id?: string | number }>( + response, + 'Instagram publish media response', + signal + ) + const id = idString(data.id) + if (!id) { + throw new Error('Publish media response missing id') + } + return id +} + +/** + * Shared transformResponse for the publish tools, which all proxy through + * internal API routes returning `{ success, output, error }`. + */ +export function createPublishTransform(fallbackError: string) { + return async (response: Response): Promise => { + const fallbackOutput = { containerId: null, mediaId: null, statusCode: null } + let data: unknown + + try { + data = await readResponseJsonWithLimit(response, { + maxBytes: INSTAGRAM_RESPONSE_MAX_BYTES, + label: 'Instagram publish response', + }) + } catch (error) { + return { + success: false, + output: fallbackOutput, + error: getErrorMessage(error, fallbackError), + } + } + + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return { + success: false, + output: fallbackOutput, + error: `${fallbackError}: invalid response`, + } + } + + const envelope = data as Record + const rawOutput = envelope.output + const output = isInstagramPublishOutput(rawOutput) ? rawOutput : fallbackOutput + const error = + typeof envelope.error === 'string' && envelope.error.trim() + ? envelope.error.trim() + : fallbackError + + if (!response.ok || envelope.success === false) { + return { success: false, output, error } + } + + if (envelope.success !== true || !isCompleteInstagramPublishOutput(rawOutput)) { + return { + success: false, + output: fallbackOutput, + error: `${fallbackError}: invalid success response`, + } + } + + return { success: true, output } + } +} + +function isInstagramPublishOutput(value: unknown): value is InstagramPublishResponse['output'] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + + const output = value as Record + return [output.containerId, output.mediaId, output.statusCode].every( + (field) => field === null || typeof field === 'string' + ) +} + +function isCompleteInstagramPublishOutput( + value: unknown +): value is InstagramPublishResponse['output'] { + if (!isInstagramPublishOutput(value)) return false + + return [value.containerId, value.mediaId, value.statusCode].every( + (field) => typeof field === 'string' && field.trim().length > 0 + ) +} + +export function parseCommaSeparated(value?: string): string[] { + if (!value) return [] + return value + .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 95bc6bb3232..4d84db3425e 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1857,6 +1857,32 @@ import { infisicalListSecretsTool, infisicalUpdateSecretTool, } from '@/tools/infisical' +import { + instagramDeleteCommentTool, + instagramDownloadMediaTool, + 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, @@ -4899,6 +4925,30 @@ export const tools: Record = { hex_update_collection: hexUpdateCollectionTool, hex_update_group: hexUpdateGroupTool, hex_update_project: hexUpdateProjectTool, + instagram_delete_comment: instagramDeleteCommentTool, + instagram_download_media: instagramDownloadMediaTool, + instagram_get_account_insights: instagramGetAccountInsightsTool, + instagram_get_container_status: instagramGetContainerStatusTool, + instagram_get_conversation_messages: instagramGetConversationMessagesTool, + instagram_get_media: instagramGetMediaTool, + instagram_get_media_insights: instagramGetMediaInsightsTool, + instagram_get_message: instagramGetMessageTool, + instagram_get_profile: instagramGetProfileTool, + instagram_get_publishing_limit: instagramGetPublishingLimitTool, + instagram_hide_comment: instagramHideCommentTool, + instagram_list_comments: instagramListCommentsTool, + instagram_list_conversations: instagramListConversationsTool, + instagram_list_media: instagramListMediaTool, + instagram_list_stories: instagramListStoriesTool, + instagram_private_reply: instagramPrivateReplyTool, + instagram_publish_carousel: instagramPublishCarouselTool, + instagram_publish_image: instagramPublishImageTool, + instagram_publish_reel: instagramPublishReelTool, + instagram_publish_story: instagramPublishStoryTool, + instagram_publish_video: instagramPublishVideoTool, + instagram_reply_to_comment: instagramReplyToCommentTool, + instagram_send_text_message: instagramSendTextMessageTool, + instagram_set_comments_enabled: instagramSetCommentsEnabledTool, instantly_activate_campaign: instantlyActivateCampaignTool, instantly_create_campaign: instantlyCreateCampaignTool, instantly_create_lead: instantlyCreateLeadTool, diff --git a/apps/sim/tools/tiktok/api-schemas.test.ts b/apps/sim/tools/tiktok/api-schemas.test.ts new file mode 100644 index 00000000000..4509e10e3e5 --- /dev/null +++ b/apps/sim/tools/tiktok/api-schemas.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + tiktokCreatorInfoApiDataSchema, + tiktokGetUserApiDataSchema, + tiktokListVideosApiDataSchema, + tiktokPostStatusApiDataSchema, + tiktokPublishInitApiDataSchema, + tiktokQueryVideosApiDataSchema, +} from '@/tools/tiktok/api-schemas' + +describe('TikTok documented response schemas', () => { + it('requires stable user identity fields', () => { + expect( + tiktokGetUserApiDataSchema.safeParse({ + user: { open_id: 'user-1', display_name: 'Creator' }, + }).success + ).toBe(true) + expect( + tiktokGetUserApiDataSchema.safeParse({ + user: { display_name: 'Creator' }, + }).success + ).toBe(false) + }) + + it('requires pagination only on list-video payloads and always requires video IDs', () => { + const videos = [{ id: 'video-1' }] + expect( + tiktokListVideosApiDataSchema.safeParse({ videos, cursor: 123, has_more: false }).success + ).toBe(true) + expect(tiktokListVideosApiDataSchema.safeParse({ videos }).success).toBe(false) + expect(tiktokQueryVideosApiDataSchema.safeParse({ videos }).success).toBe(true) + expect(tiktokQueryVideosApiDataSchema.safeParse({ videos: [{}] }).success).toBe(false) + }) + + it('requires the documented creator capability fields', () => { + const complete = { + creator_avatar_url: 'https://example.com/avatar', + creator_username: 'creator', + creator_nickname: 'Creator', + privacy_level_options: ['SELF_ONLY'], + comment_disabled: false, + duet_disabled: false, + stitch_disabled: false, + max_video_post_duration_sec: 180, + } + + expect(tiktokCreatorInfoApiDataSchema.safeParse(complete).success).toBe(true) + const { privacy_level_options: _privacyLevelOptions, ...incomplete } = complete + expect(tiktokCreatorInfoApiDataSchema.safeParse(incomplete).success).toBe(false) + }) + + it('requires publish initialization identifiers and post status', () => { + expect( + tiktokPublishInitApiDataSchema.safeParse({ + publish_id: 'publish-1', + upload_url: 'https://upload.example/video', + }).success + ).toBe(true) + expect(tiktokPublishInitApiDataSchema.safeParse({ publish_id: 'publish-1' }).success).toBe( + false + ) + expect(tiktokPostStatusApiDataSchema.safeParse({ status: 'PROCESSING_UPLOAD' }).success).toBe( + true + ) + expect(tiktokPostStatusApiDataSchema.safeParse({}).success).toBe(false) + }) +}) diff --git a/apps/sim/tools/tiktok/api-schemas.ts b/apps/sim/tools/tiktok/api-schemas.ts index 82d1606c0f0..1e244a98f5e 100644 --- a/apps/sim/tools/tiktok/api-schemas.ts +++ b/apps/sim/tools/tiktok/api-schemas.ts @@ -2,11 +2,11 @@ import { z } from 'zod' /** Raw user object returned by TikTok's user info API. */ export const tiktokApiUserSchema = z.object({ - open_id: z.string().optional(), + open_id: z.string(), union_id: z.string().optional(), avatar_url: z.string().optional(), avatar_large_url: z.string().optional(), - display_name: z.string().optional(), + display_name: z.string(), bio_description: z.string().optional(), profile_deep_link: z.string().optional(), is_verified: z.boolean().optional(), @@ -19,12 +19,12 @@ export const tiktokApiUserSchema = z.object({ /** Data payload returned by TikTok's user info API. */ export const tiktokGetUserApiDataSchema = z.object({ - user: tiktokApiUserSchema.optional(), + user: tiktokApiUserSchema, }) /** Raw video object returned by TikTok's video APIs. */ export const tiktokApiVideoSchema = z.object({ - id: z.string().optional(), + id: z.string(), title: z.string().optional(), cover_image_url: z.string().optional(), embed_link: z.string().optional(), @@ -41,34 +41,39 @@ export const tiktokApiVideoSchema = z.object({ share_count: z.number().optional(), }) -/** Data payload returned by TikTok's list/query video APIs. */ -export const tiktokVideosApiDataSchema = z.object({ - videos: z.array(tiktokApiVideoSchema).optional(), - cursor: z.number().optional(), - has_more: z.boolean().optional(), +/** Data payload returned by TikTok's paginated video list API. */ +export const tiktokListVideosApiDataSchema = z.object({ + videos: z.array(tiktokApiVideoSchema), + cursor: z.number(), + has_more: z.boolean(), +}) + +/** Data payload returned by TikTok's video query API. */ +export const tiktokQueryVideosApiDataSchema = z.object({ + videos: z.array(tiktokApiVideoSchema), }) /** Data payload returned by TikTok's creator info API. */ export const tiktokCreatorInfoApiDataSchema = z.object({ - creator_avatar_url: z.string().optional(), - creator_username: z.string().optional(), - creator_nickname: z.string().optional(), - privacy_level_options: z.array(z.string()).optional(), - comment_disabled: z.boolean().optional(), - duet_disabled: z.boolean().optional(), - stitch_disabled: z.boolean().optional(), - max_video_post_duration_sec: z.number().optional(), + creator_avatar_url: z.string(), + creator_username: z.string(), + creator_nickname: z.string(), + privacy_level_options: z.array(z.string()), + comment_disabled: z.boolean(), + duet_disabled: z.boolean(), + stitch_disabled: z.boolean(), + max_video_post_duration_sec: z.number(), }) /** Data payload returned by TikTok's publish initialization APIs. */ export const tiktokPublishInitApiDataSchema = z.object({ - publish_id: z.string().optional(), - upload_url: z.string().optional(), + publish_id: z.string(), + upload_url: z.string(), }) /** Data payload returned by TikTok's post status API. */ export const tiktokPostStatusApiDataSchema = z.object({ - status: z.string().optional(), + status: z.string(), fail_reason: z.string().optional(), uploaded_bytes: z.number().optional(), downloaded_bytes: z.number().optional(), diff --git a/apps/sim/tools/tiktok/direct_post_video.test.ts b/apps/sim/tools/tiktok/direct_post_video.test.ts new file mode 100644 index 00000000000..38130270ba7 --- /dev/null +++ b/apps/sim/tools/tiktok/direct_post_video.test.ts @@ -0,0 +1,32 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { tiktokDirectPostVideoTool } from '@/tools/tiktok/direct_post_video' +import type { TikTokDirectPostVideoParams } from '@/tools/tiktok/types' + +describe('tiktokDirectPostVideoTool', () => { + it('fails closed until one-use human approval is supported', () => { + const params = { + accessToken: 'token', + brandContentToggle: false, + disableComment: false, + disableDuet: false, + disableStitch: false, + file: { + id: 'file-1', + key: 'workspace/workspace-1/file-1', + name: 'video.mp4', + size: 1024, + type: 'video/mp4', + url: '/api/files/serve?key=workspace%2Fworkspace-1%2Ffile-1', + }, + musicUsageConsent: 'accepted', + privacyLevel: 'SELF_ONLY', + } satisfies TikTokDirectPostVideoParams + + expect(() => tiktokDirectPostVideoTool.request.body?.(params)).toThrow( + 'TikTok Direct Post is unavailable' + ) + }) +}) diff --git a/apps/sim/tools/tiktok/direct_post_video.ts b/apps/sim/tools/tiktok/direct_post_video.ts index dccb1cdde50..7522f0662d1 100644 --- a/apps/sim/tools/tiktok/direct_post_video.ts +++ b/apps/sim/tools/tiktok/direct_post_video.ts @@ -5,23 +5,8 @@ import type { import { readTikTokPublishInitResponse, toTikTokPublishToolResponse } from '@/tools/tiktok/utils' import type { ToolConfig } from '@/tools/types' -function buildPostInfo(params: TikTokDirectPostVideoParams): Record { - const postInfo: Record = { - brand_content_toggle: params.brandContentToggle, - brand_organic_toggle: params.brandOrganicToggle ?? false, - privacy_level: params.privacyLevel, - disable_duet: params.disableDuet, - disable_stitch: params.disableStitch, - disable_comment: params.disableComment, - } - - if (params.title) postInfo.title = params.title - if (params.videoCoverTimestampMs !== undefined) { - postInfo.video_cover_timestamp_ms = params.videoCoverTimestampMs - } - if (params.isAigc !== undefined) postInfo.is_aigc = params.isAigc - return postInfo -} +const DIRECT_POST_UNAVAILABLE_MESSAGE = + 'TikTok Direct Post is unavailable until Sim can bind TikTok-required human review and consent to one exact upload. Use Upload Video Draft for review and publishing in TikTok.' export const tiktokDirectPostVideoTool: ToolConfig< TikTokDirectPostVideoParams, @@ -30,7 +15,7 @@ export const tiktokDirectPostVideoTool: ToolConfig< id: 'tiktok_direct_post_video', name: 'TikTok Direct Post Video', description: - 'Publish a video to TikTok by uploading a file from the workflow. Rate limit: 6 requests per minute per user.', + 'Unavailable compatibility tool. Use Upload Video Draft so the account owner can review and publish in TikTok.', version: '1.0.0', oauth: { @@ -123,16 +108,8 @@ export const tiktokDirectPostVideoTool: ToolConfig< headers: () => ({ 'Content-Type': 'application/json', }), - body: (params: TikTokDirectPostVideoParams) => { - const postInfo = buildPostInfo(params) - - return { - accessToken: params.accessToken, - mode: 'direct', - file: params.file, - postInfo, - musicUsageConsent: params.musicUsageConsent, - } + body: () => { + throw new Error(DIRECT_POST_UNAVAILABLE_MESSAGE) }, }, diff --git a/apps/sim/tools/tiktok/get_post_status.test.ts b/apps/sim/tools/tiktok/get_post_status.test.ts new file mode 100644 index 00000000000..960a6e64d67 --- /dev/null +++ b/apps/sim/tools/tiktok/get_post_status.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { tiktokGetPostStatusTool } from '@/tools/tiktok/get_post_status' + +describe('TikTok Get Post Status OAuth scopes', () => { + it("does not model TikTok's publish-or-upload authorization as all-of scopes", () => { + expect(tiktokGetPostStatusTool.oauth).toEqual({ + required: true, + provider: 'tiktok', + }) + }) +}) diff --git a/apps/sim/tools/tiktok/get_post_status.ts b/apps/sim/tools/tiktok/get_post_status.ts index 5e18f2f3b6e..99ff8ee84c8 100644 --- a/apps/sim/tools/tiktok/get_post_status.ts +++ b/apps/sim/tools/tiktok/get_post_status.ts @@ -26,7 +26,6 @@ export const tiktokGetPostStatusTool: ToolConfig< oauth: { required: true, provider: 'tiktok', - requiredScopes: ['video.publish', 'video.upload'], }, params: { diff --git a/apps/sim/tools/tiktok/get_user.test.ts b/apps/sim/tools/tiktok/get_user.test.ts new file mode 100644 index 00000000000..74c1cf3882b --- /dev/null +++ b/apps/sim/tools/tiktok/get_user.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { tiktokGetUserTool } from '@/tools/tiktok/get_user' +import { TIKTOK_USER_FIELDS } from '@/tools/tiktok/utils' + +function getRequestUrl(fields?: string): string { + const url = tiktokGetUserTool.request.url + if (typeof url !== 'function') throw new Error('Expected a dynamic TikTok user URL') + return url({ accessToken: 'token', fields }) +} + +describe('TikTok Get User fields', () => { + it('always requests stable identity fields and de-duplicates custom fields', () => { + const url = new URL(getRequestUrl('avatar_large_url,open_id,avatar_large_url')) + + expect(url.searchParams.get('fields')).toBe('open_id,display_name,avatar_large_url') + }) + + it('uses the canonical field set when the custom value is blank', () => { + const url = new URL(getRequestUrl(' ')) + + expect(url.searchParams.get('fields')).toBe(TIKTOK_USER_FIELDS) + }) + + it('rejects unsupported custom fields deterministically', () => { + expect(() => getRequestUrl('open_id,not_a_field,also_bad,not_a_field')).toThrow( + 'Unsupported TikTok user field(s): not_a_field, also_bad' + ) + }) +}) diff --git a/apps/sim/tools/tiktok/get_user.ts b/apps/sim/tools/tiktok/get_user.ts index 384196a152d..901d1923a49 100644 --- a/apps/sim/tools/tiktok/get_user.ts +++ b/apps/sim/tools/tiktok/get_user.ts @@ -1,8 +1,30 @@ import { tiktokGetUserApiDataSchema } from '@/tools/tiktok/api-schemas' import type { TikTokGetUserParams, TikTokGetUserResponse } from '@/tools/tiktok/types' -import { readTikTokApiResponse, TIKTOK_USER_FIELDS } from '@/tools/tiktok/utils' +import { + readTikTokApiResponse, + TIKTOK_USER_FIELD_NAMES, + TIKTOK_USER_FIELDS, +} from '@/tools/tiktok/utils' import type { ToolConfig } from '@/tools/types' +const REQUIRED_USER_FIELDS = ['open_id', 'display_name'] as const +const USER_FIELD_ALLOWLIST = new Set(TIKTOK_USER_FIELD_NAMES) + +function resolveUserFields(fields: string | undefined): string { + if (!fields?.trim()) return TIKTOK_USER_FIELDS + + const requested = fields + .split(',') + .map((field) => field.trim()) + .filter(Boolean) + const invalid = requested.filter((field) => !USER_FIELD_ALLOWLIST.has(field)) + if (invalid.length > 0) { + throw new Error(`Unsupported TikTok user field(s): ${[...new Set(invalid)].join(', ')}`) + } + + return [...new Set([...REQUIRED_USER_FIELDS, ...requested])].join(',') +} + function emptyUserOutput(): TikTokGetUserResponse['output'] { return { openId: '', @@ -45,13 +67,13 @@ export const tiktokGetUserTool: ToolConfig { - const fields = params.fields || TIKTOK_USER_FIELDS + const fields = resolveUserFields(params.fields) return `https://open.tiktokapis.com/v2/user/info/?fields=${encodeURIComponent(fields)}` }, method: 'GET', diff --git a/apps/sim/tools/tiktok/list_videos.ts b/apps/sim/tools/tiktok/list_videos.ts index 14ba8ed8ecf..260a591f258 100644 --- a/apps/sim/tools/tiktok/list_videos.ts +++ b/apps/sim/tools/tiktok/list_videos.ts @@ -1,4 +1,4 @@ -import { tiktokVideosApiDataSchema } from '@/tools/tiktok/api-schemas' +import { tiktokListVideosApiDataSchema } from '@/tools/tiktok/api-schemas' import { TIKTOK_VIDEO_OUTPUT_PROPERTIES, type TikTokListVideosParams, @@ -66,7 +66,7 @@ export const tiktokListVideosTool: ToolConfig => { - const { data, error } = await readTikTokApiResponse(response, tiktokVideosApiDataSchema) + const { data, error } = await readTikTokApiResponse(response, tiktokListVideosApiDataSchema) if (error) { return { @@ -80,14 +80,26 @@ export const tiktokListVideosTool: ToolConfig { + it('exposes the temporary avatar as a composable file output', async () => { + const response = Response.json({ + data: { + creator_avatar_url: 'https://p16-sign.tiktokcdn-us.com/avatar.jpeg', + creator_username: 'creator', + creator_nickname: 'Creator', + privacy_level_options: ['SELF_ONLY'], + comment_disabled: true, + duet_disabled: false, + stitch_disabled: true, + max_video_post_duration_sec: 180, + }, + error: { code: 'ok' }, + }) + + await expect(tiktokQueryCreatorInfoTool.transformResponse?.(response)).resolves.toMatchObject({ + success: true, + output: { + creatorAvatarUrl: 'https://p16-sign.tiktokcdn-us.com/avatar.jpeg', + creatorAvatarFile: { + name: 'creator-avatar.jpg', + mimeType: 'image/jpeg', + url: 'https://p16-sign.tiktokcdn-us.com/avatar.jpeg', + }, + privacyLevelOptions: ['SELF_ONLY'], + }, + }) + expect(tiktokQueryCreatorInfoTool.outputs?.creatorAvatarFile).toMatchObject({ + type: 'file', + optional: true, + }) + expect(tiktokQueryCreatorInfoTool.outputs?.creatorAvatarUrl?.description).toMatch(/two hours/i) + }) + + it('omits the file descriptor when TikTok returns an empty avatar URL', async () => { + const response = Response.json({ + data: { + creator_avatar_url: '', + creator_username: 'creator', + creator_nickname: 'Creator', + privacy_level_options: ['SELF_ONLY'], + comment_disabled: false, + duet_disabled: false, + stitch_disabled: false, + max_video_post_duration_sec: 180, + }, + error: { code: 'ok' }, + }) + + const result = await tiktokQueryCreatorInfoTool.transformResponse?.(response) + + expect(result?.success).toBe(true) + expect(result?.output.creatorAvatarUrl).toBe('') + expect(result?.output).not.toHaveProperty('creatorAvatarFile') + expect(tiktokQueryCreatorInfoTool.outputs?.privacyLevelOptions?.items).toEqual({ + type: 'string', + description: 'Privacy level currently available to the authenticated creator', + }) + }) +}) diff --git a/apps/sim/tools/tiktok/query_creator_info.ts b/apps/sim/tools/tiktok/query_creator_info.ts index f39f767daa4..2b969b9aa1e 100644 --- a/apps/sim/tools/tiktok/query_creator_info.ts +++ b/apps/sim/tools/tiktok/query_creator_info.ts @@ -26,7 +26,7 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< id: 'tiktok_query_creator_info', name: 'TikTok Query Creator Info', description: - 'Check if the authenticated TikTok user can post content and retrieve their available privacy options, interaction settings, and maximum video duration. Required before any post per TikTok UX guidelines.', + "Inspect the authenticated creator's Direct Post capabilities, privacy options, interaction settings, and maximum video duration. Direct Post is currently unavailable in Sim; Upload Video Draft does not require this step.", version: '1.0.0', oauth: { @@ -80,6 +80,13 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< success: true, output: { creatorAvatarUrl: creatorInfo.creator_avatar_url ?? null, + ...(creatorInfo.creator_avatar_url && { + creatorAvatarFile: { + name: `${creatorInfo.creator_username || 'tiktok-creator'}-avatar.jpg`, + mimeType: 'image/jpeg', + url: creatorInfo.creator_avatar_url, + }, + }), creatorUsername: creatorInfo.creator_username ?? null, creatorNickname: creatorInfo.creator_nickname ?? null, privacyLevelOptions: creatorInfo.privacy_level_options ?? [], @@ -94,7 +101,14 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< outputs: { creatorAvatarUrl: { type: 'string', - description: 'URL of the creator avatar', + description: + 'Temporary URL of the current creator avatar. TikTok documents this URL as expiring two hours after it is returned.', + optional: true, + }, + creatorAvatarFile: { + type: 'file', + description: + 'Durable workflow-file copy of the current creator avatar, suitable for chaining into file-consuming blocks such as email attachments.', optional: true, }, creatorUsername: { @@ -111,6 +125,10 @@ export const tiktokQueryCreatorInfoTool: ToolConfig< type: 'array', description: 'Available privacy levels for posting (e.g., PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)', + items: { + type: 'string', + description: 'Privacy level currently available to the authenticated creator', + }, }, commentDisabled: { type: 'boolean', diff --git a/apps/sim/tools/tiktok/query_videos.ts b/apps/sim/tools/tiktok/query_videos.ts index b9e89c2a64f..636f515272b 100644 --- a/apps/sim/tools/tiktok/query_videos.ts +++ b/apps/sim/tools/tiktok/query_videos.ts @@ -1,4 +1,4 @@ -import { tiktokVideosApiDataSchema } from '@/tools/tiktok/api-schemas' +import { tiktokQueryVideosApiDataSchema } from '@/tools/tiktok/api-schemas' import { TIKTOK_VIDEO_OUTPUT_PROPERTIES, type TikTokQueryVideosParams, @@ -65,7 +65,7 @@ export const tiktokQueryVideosTool: ToolConfig => { - const { data, error } = await readTikTokApiResponse(response, tiktokVideosApiDataSchema) + const { data, error } = await readTikTokApiResponse(response, tiktokQueryVideosApiDataSchema) if (error) { return { @@ -77,7 +77,17 @@ export const tiktokQueryVideosTool: ToolConfig { @@ -53,9 +54,22 @@ describe('TikTok tool utilities', () => { }) }) + it('rejects oversized TikTok JSON responses without materializing them unboundedly', async () => { + const response = new Response('x'.repeat(TIKTOK_API_RESPONSE_MAX_BYTES + 1)) + + await expect(readTikTokApiResponse(response, tiktokPublishInitApiDataSchema)).resolves.toEqual({ + data: null, + error: { + code: 'invalid_response', + message: 'TikTok response exceeded the maximum supported size or could not be read', + }, + rawBody: '', + }) + }) + it('normalizes direct TikTok publish responses', async () => { const response = Response.json({ - data: { publish_id: 'publish-1' }, + data: { publish_id: 'publish-1', upload_url: 'https://upload.example/video' }, error: { code: 'ok' }, }) diff --git a/apps/sim/tools/tiktok/utils.ts b/apps/sim/tools/tiktok/utils.ts index 012a40fd641..56617c3435e 100644 --- a/apps/sim/tools/tiktok/utils.ts +++ b/apps/sim/tools/tiktok/utils.ts @@ -1,15 +1,33 @@ import { truncate } from '@sim/utils/string' import type { ZodType } from 'zod' +import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import { type TikTokApiVideo, tiktokPublishInitApiDataSchema } from '@/tools/tiktok/api-schemas' import type { TikTokApiError, TikTokPublishResponse, TikTokVideo } from '@/tools/tiktok/types' +export const TIKTOK_API_RESPONSE_MAX_BYTES = 1024 * 1024 + /** * Default fields requested from TikTok's `/v2/user/info/` endpoint, covering the * `user.info.basic`, `user.info.profile`, and `user.info.stats` scopes. * `avatar_url` and `avatar_large_url` feed the file-typed `avatarFile` output. */ -export const TIKTOK_USER_FIELDS = - 'open_id,union_id,avatar_url,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count' +export const TIKTOK_USER_FIELD_NAMES = [ + 'open_id', + 'union_id', + 'avatar_url', + 'avatar_large_url', + 'display_name', + 'bio_description', + 'profile_deep_link', + 'is_verified', + 'username', + 'follower_count', + 'following_count', + 'likes_count', + 'video_count', +] as const + +export const TIKTOK_USER_FIELDS = TIKTOK_USER_FIELD_NAMES.join(',') /** * Fields requested from TikTok's `/v2/video/list/` and `/v2/video/query/` endpoints. @@ -56,6 +74,10 @@ interface TikTokPublishInitResult { error?: string } +interface ReadTikTokApiResponseOptions { + signal?: AbortSignal +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } @@ -87,8 +109,28 @@ function httpError(response: Response, rawBody: string, message?: string): TikTo } } -async function readJsonObject(response: Response): Promise { - const rawBody = await response.text() +async function readJsonObject( + response: Response, + options: ReadTikTokApiResponseOptions = {} +): Promise { + let rawBody: string + try { + rawBody = await readResponseTextWithLimit(response, { + maxBytes: TIKTOK_API_RESPONSE_MAX_BYTES, + label: 'TikTok API response', + signal: options.signal, + }) + } catch { + options.signal?.throwIfAborted() + return { + body: null, + error: { + code: 'invalid_response', + message: 'TikTok response exceeded the maximum supported size or could not be read', + }, + rawBody: '', + } + } let parsed: unknown try { @@ -159,9 +201,10 @@ function parseApiEnvelope( /** Reads and normalizes a typed TikTok API envelope. */ export async function readTikTokApiResponse( response: Response, - dataSchema: ZodType + dataSchema: ZodType, + options: ReadTikTokApiResponseOptions = {} ): Promise> { - return parseApiEnvelope(response, await readJsonObject(response), dataSchema) + return parseApiEnvelope(response, await readJsonObject(response, options), dataSchema) } /** Enforces TikTok's bounded array request limits before making a network request. */ diff --git a/apps/sim/tools/tiktok/videos.test.ts b/apps/sim/tools/tiktok/videos.test.ts new file mode 100644 index 00000000000..4ddab7ee9fc --- /dev/null +++ b/apps/sim/tools/tiktok/videos.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { tiktokListVideosTool } from '@/tools/tiktok/list_videos' +import { tiktokQueryVideosTool } from '@/tools/tiktok/query_videos' + +describe('TikTok video response boundaries', () => { + it('requires list pagination while accepting query responses without it', async () => { + const listResult = await tiktokListVideosTool.transformResponse?.( + Response.json({ + data: { videos: [{ id: 'video-1' }], cursor: 123, has_more: false }, + error: { code: 'ok' }, + }) + ) + const queryResult = await tiktokQueryVideosTool.transformResponse?.( + Response.json({ + data: { videos: [{ id: 'video-1' }] }, + error: { code: 'ok' }, + }) + ) + + expect(listResult).toMatchObject({ + success: true, + output: { videos: [{ id: 'video-1' }], cursor: 123, hasMore: false }, + }) + expect(queryResult).toMatchObject({ + success: true, + output: { videos: [{ id: 'video-1' }] }, + }) + }) + + it('rejects a malformed list payload instead of returning empty success', async () => { + const result = await tiktokListVideosTool.transformResponse?.( + Response.json({ + data: { videos: [{ id: 'video-1' }] }, + error: { code: 'ok' }, + }) + ) + + expect(result).toEqual({ + success: false, + output: { videos: [], cursor: null, hasMore: false }, + error: 'TikTok returned an unexpected data shape', + }) + }) + + it('rejects a null query payload instead of returning empty success', async () => { + const result = await tiktokQueryVideosTool.transformResponse?.( + Response.json({ data: null, error: { code: 'ok' } }) + ) + + expect(result).toEqual({ + success: false, + output: { videos: [] }, + error: 'No video query data returned', + }) + }) +}) diff --git a/apps/sim/triggers/tiktok/index.ts b/apps/sim/triggers/tiktok/index.ts index 87d4219c68c..2f9321fd3c5 100644 --- a/apps/sim/triggers/tiktok/index.ts +++ b/apps/sim/triggers/tiktok/index.ts @@ -1,8 +1,8 @@ -export { tiktokAuthorizationRemovedTrigger } from './authorization_removed' -export { tiktokPostInboxDeliveredTrigger } from './post_inbox_delivered' -export { tiktokPostNoLongerPublicTrigger } from './post_no_longer_public' -export { tiktokPostPubliclyAvailableTrigger } from './post_publicly_available' -export { tiktokPostPublishCompleteTrigger } from './post_publish_complete' -export { tiktokPostPublishFailedTrigger } from './post_publish_failed' -export { tiktokVideoPublishCompletedTrigger } from './video_publish_completed' -export { tiktokVideoUploadFailedTrigger } from './video_upload_failed' +export { tiktokAuthorizationRemovedTrigger } from '@/triggers/tiktok/authorization_removed' +export { tiktokPostInboxDeliveredTrigger } from '@/triggers/tiktok/post_inbox_delivered' +export { tiktokPostNoLongerPublicTrigger } from '@/triggers/tiktok/post_no_longer_public' +export { tiktokPostPubliclyAvailableTrigger } from '@/triggers/tiktok/post_publicly_available' +export { tiktokPostPublishCompleteTrigger } from '@/triggers/tiktok/post_publish_complete' +export { tiktokPostPublishFailedTrigger } from '@/triggers/tiktok/post_publish_failed' +export { tiktokVideoPublishCompletedTrigger } from '@/triggers/tiktok/video_publish_completed' +export { tiktokVideoUploadFailedTrigger } from '@/triggers/tiktok/video_upload_failed' diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index ea6557a151d..61afe1084db 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 949, - zodRoutes: 949, + totalRoutes: 958, + zodRoutes: 958, nonZodRoutes: 0, } as const @@ -25,7 +25,7 @@ const BOUNDARY_POLICY_BASELINE = { clientHookRawFetches: 0, clientSameOriginApiFetches: 0, doubleCasts: 8, - rawJsonReads: 8, + rawJsonReads: 6, untypedResponses: 0, annotationsMissingReason: 0, } as const