mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-06 04:35:03 -05:00
Compare commits
5 Commits
feature/ti
...
sim-614
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9dc7568a | ||
|
|
a9b7d75d87 | ||
|
|
0449804ffb | ||
|
|
c286f3ed24 | ||
|
|
b738550815 |
@@ -6,11 +6,9 @@ import { getSession } from '@/lib/auth'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getMicrosoftRefreshTokenExpiry,
|
||||
getTikTokRefreshTokenExpiry,
|
||||
isMicrosoftProvider,
|
||||
isTikTokProvider,
|
||||
PROACTIVE_REFRESH_THRESHOLD_DAYS,
|
||||
} from '@/lib/oauth/utils'
|
||||
} from '@/lib/oauth/microsoft'
|
||||
|
||||
const logger = createLogger('OAuthUtilsAPI')
|
||||
|
||||
@@ -222,13 +220,13 @@ export async function refreshAccessTokenIfNeeded(
|
||||
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
||||
|
||||
// Check if we should proactively refresh to prevent refresh token expiry
|
||||
// This applies to providers with expiring refresh tokens (Microsoft: 90 days, TikTok: 365 days)
|
||||
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
||||
const proactiveRefreshThreshold = new Date(
|
||||
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
||||
)
|
||||
const refreshTokenNeedsProactiveRefresh =
|
||||
!!credential.refreshToken &&
|
||||
(isMicrosoftProvider(credential.providerId) || isTikTokProvider(credential.providerId)) &&
|
||||
isMicrosoftProvider(credential.providerId) &&
|
||||
refreshTokenExpiresAt &&
|
||||
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
||||
|
||||
@@ -273,8 +271,6 @@ export async function refreshAccessTokenIfNeeded(
|
||||
|
||||
if (isMicrosoftProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
} else if (isTikTokProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getTikTokRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
// Update the token in the database
|
||||
@@ -325,13 +321,13 @@ export async function refreshTokenIfNeeded(
|
||||
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
|
||||
|
||||
// Check if we should proactively refresh to prevent refresh token expiry
|
||||
// This applies to providers with expiring refresh tokens (Microsoft: 90 days, TikTok: 365 days)
|
||||
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
|
||||
const proactiveRefreshThreshold = new Date(
|
||||
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
|
||||
)
|
||||
const refreshTokenNeedsProactiveRefresh =
|
||||
!!credential.refreshToken &&
|
||||
(isMicrosoftProvider(credential.providerId) || isTikTokProvider(credential.providerId)) &&
|
||||
isMicrosoftProvider(credential.providerId) &&
|
||||
refreshTokenExpiresAt &&
|
||||
refreshTokenExpiresAt <= proactiveRefreshThreshold
|
||||
|
||||
@@ -372,8 +368,6 @@ export async function refreshTokenIfNeeded(
|
||||
|
||||
if (isMicrosoftProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
|
||||
} else if (isTikTokProvider(credential.providerId)) {
|
||||
updateData.refreshTokenExpiresAt = getTikTokRefreshTokenExpiry()
|
||||
}
|
||||
|
||||
await db.update(account).set(updateData).where(eq(account.id, credentialId))
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('TikTokAuthorize')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const clientKey = env.TIKTOK_CLIENT_ID
|
||||
|
||||
if (!clientKey) {
|
||||
logger.error('TIKTOK_CLIENT_ID not configured')
|
||||
return NextResponse.json({ error: 'TikTok client key not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get the return URL from query params or use default
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const returnUrl = searchParams.get('returnUrl') || `${getBaseUrl()}/workspace`
|
||||
|
||||
const baseUrl = getBaseUrl()
|
||||
const redirectUri = `${baseUrl}/api/auth/tiktok/callback`
|
||||
|
||||
// Generate a random state for CSRF protection
|
||||
const state = Buffer.from(
|
||||
JSON.stringify({
|
||||
returnUrl,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
).toString('base64url')
|
||||
|
||||
// TikTok scopes
|
||||
const scopes = [
|
||||
'user.info.basic',
|
||||
'user.info.profile',
|
||||
'user.info.stats',
|
||||
'video.list',
|
||||
'video.publish',
|
||||
]
|
||||
|
||||
// Build TikTok authorization URL with client_key (not client_id)
|
||||
// Note: TikTok expects raw commas in scope parameter, not URL-encoded %2C
|
||||
// So we manually construct the URL to avoid automatic encoding
|
||||
const scopeString = scopes.join(',')
|
||||
const encodedRedirectUri = encodeURIComponent(redirectUri)
|
||||
const encodedState = encodeURIComponent(state)
|
||||
|
||||
const authUrl = `https://www.tiktok.com/v2/auth/authorize/?client_key=${clientKey}&response_type=code&scope=${scopeString}&redirect_uri=${encodedRedirectUri}&state=${encodedState}`
|
||||
|
||||
logger.info('Redirecting to TikTok authorization', {
|
||||
clientKey: clientKey ? `${clientKey.substring(0, 8)}...` : 'NOT SET',
|
||||
redirectUri,
|
||||
scopes: scopeString,
|
||||
fullUrl: authUrl,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(authUrl)
|
||||
} catch (error) {
|
||||
logger.error('Error initiating TikTok authorization:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
const logger = createLogger('TikTokCallback')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.error('No session found during TikTok callback')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
// Handle errors from TikTok
|
||||
if (error) {
|
||||
logger.error('TikTok authorization error:', { error, errorDescription })
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/workspace?error=tiktok_auth_failed&message=${encodeURIComponent(errorDescription || error)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
logger.error('No authorization code received from TikTok')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=no_code`)
|
||||
}
|
||||
|
||||
// Parse state to get return URL
|
||||
let returnUrl = `${baseUrl}/workspace`
|
||||
if (state) {
|
||||
try {
|
||||
const stateData = JSON.parse(Buffer.from(state, 'base64url').toString())
|
||||
returnUrl = stateData.returnUrl || returnUrl
|
||||
} catch {
|
||||
logger.warn('Failed to parse state parameter')
|
||||
}
|
||||
}
|
||||
|
||||
const clientKey = env.TIKTOK_CLIENT_ID
|
||||
const clientSecret = env.TIKTOK_CLIENT_SECRET
|
||||
|
||||
if (!clientKey || !clientSecret) {
|
||||
logger.error('TikTok credentials not configured')
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=config_error`)
|
||||
}
|
||||
|
||||
const redirectUri = `${baseUrl}/api/auth/tiktok/callback`
|
||||
|
||||
// Exchange authorization code for access token
|
||||
// TikTok uses client_key instead of client_id
|
||||
const tokenResponse = await fetch('https://open.tiktokapis.com/v2/oauth/token/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_key: clientKey,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: redirectUri,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
logger.error('Failed to exchange code for token:', {
|
||||
status: tokenResponse.status,
|
||||
error: errorText,
|
||||
})
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=token_exchange_failed`)
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json()
|
||||
|
||||
if (tokenData.error) {
|
||||
logger.error('TikTok token error:', tokenData)
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/workspace?error=tiktok_token_error&message=${encodeURIComponent(tokenData.error_description || tokenData.error)}`
|
||||
)
|
||||
}
|
||||
|
||||
const { access_token, refresh_token, expires_in, open_id, scope } = tokenData
|
||||
|
||||
if (!access_token) {
|
||||
logger.error('No access token in TikTok response:', tokenData)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=no_access_token`)
|
||||
}
|
||||
|
||||
// Store the tokens by calling the store endpoint
|
||||
const storeResponse = await fetch(`${baseUrl}/api/auth/tiktok/store`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: request.headers.get('cookie') || '',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
expiresIn: expires_in,
|
||||
openId: open_id,
|
||||
scope,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!storeResponse.ok) {
|
||||
const storeError = await storeResponse.text()
|
||||
logger.error('Failed to store TikTok tokens:', storeError)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=store_failed`)
|
||||
}
|
||||
|
||||
logger.info('TikTok authorization successful')
|
||||
return NextResponse.redirect(`${returnUrl}?tiktok_connected=true`)
|
||||
} catch (error) {
|
||||
logger.error('Error in TikTok callback:', error)
|
||||
return NextResponse.redirect(`${baseUrl}/workspace?error=callback_error`)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getTikTokRefreshTokenExpiry } from '@/lib/oauth/utils'
|
||||
import { safeAccountInsert } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/../../packages/db'
|
||||
import { account } from '@/../../packages/db/schema'
|
||||
|
||||
const logger = createLogger('TikTokStore')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn('Unauthorized attempt to store TikTok token')
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { accessToken, refreshToken, expiresIn, openId, scope } = body
|
||||
|
||||
if (!accessToken || !openId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Access token and open_id required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch user info from TikTok to get display name
|
||||
let displayName = 'TikTok User'
|
||||
let avatarUrl: string | undefined
|
||||
|
||||
try {
|
||||
const userResponse = await fetch(
|
||||
'https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,avatar_url,display_name',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (userResponse.ok) {
|
||||
const userData = await userResponse.json()
|
||||
if (userData.data?.user) {
|
||||
displayName = userData.data.user.display_name || displayName
|
||||
avatarUrl = userData.data.user.avatar_url
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch TikTok user info:', error)
|
||||
}
|
||||
|
||||
const existing = await db.query.account.findFirst({
|
||||
where: and(eq(account.userId, session.user.id), eq(account.providerId, 'tiktok')),
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const accessTokenExpiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : undefined
|
||||
const refreshTokenExpiresAt = getTikTokRefreshTokenExpiry()
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(account)
|
||||
.set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accountId: openId,
|
||||
scope:
|
||||
scope || 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish',
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(account.id, existing.id))
|
||||
|
||||
logger.info('Updated existing TikTok account', { accountId: openId })
|
||||
} else {
|
||||
await safeAccountInsert(
|
||||
{
|
||||
id: `tiktok_${session.user.id}_${Date.now()}`,
|
||||
userId: session.user.id,
|
||||
providerId: 'tiktok',
|
||||
accountId: openId,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scope:
|
||||
scope || 'user.info.basic,user.info.profile,user.info.stats,video.list,video.publish',
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{ provider: 'TikTok', identifier: openId }
|
||||
)
|
||||
|
||||
logger.info('Created new TikTok account', { accountId: openId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Error storing TikTok token:', error)
|
||||
return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { verifyCronAuth } from '@/lib/auth/internal'
|
||||
const logger = createLogger('CleanupStaleExecutions')
|
||||
|
||||
const STALE_THRESHOLD_MINUTES = 30
|
||||
const MAX_INT32 = 2_147_483_647
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -45,13 +46,14 @@ export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const staleDurationMs = Date.now() - new Date(execution.startedAt).getTime()
|
||||
const staleDurationMinutes = Math.round(staleDurationMs / 60000)
|
||||
const totalDurationMs = Math.min(staleDurationMs, MAX_INT32)
|
||||
|
||||
await db
|
||||
.update(workflowExecutionLogs)
|
||||
.set({
|
||||
status: 'failed',
|
||||
endedAt: new Date(),
|
||||
totalDurationMs: staleDurationMs,
|
||||
totalDurationMs,
|
||||
executionData: sql`jsonb_set(
|
||||
COALESCE(execution_data, '{}'::jsonb),
|
||||
ARRAY['error'],
|
||||
|
||||
@@ -284,7 +284,7 @@ async function handleToolsCall(
|
||||
content: [
|
||||
{ type: 'text', text: JSON.stringify(executeResult.output || executeResult, null, 2) },
|
||||
],
|
||||
isError: !executeResult.success,
|
||||
isError: executeResult.success === false,
|
||||
}
|
||||
|
||||
return NextResponse.json(createResponse(id, result))
|
||||
|
||||
@@ -20,6 +20,7 @@ import { z } from 'zod'
|
||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasAccessControlAccess } from '@/lib/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -501,6 +502,18 @@ export async function PUT(
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'accepted') {
|
||||
try {
|
||||
await syncUsageLimitsFromSubscription(session.user.id)
|
||||
} catch (syncError) {
|
||||
logger.error('Failed to sync usage limits after joining org', {
|
||||
userId: session.user.id,
|
||||
organizationId,
|
||||
error: syncError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Organization invitation ${status}`, {
|
||||
organizationId,
|
||||
invitationId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('SubscriptionTransferAPI')
|
||||
|
||||
@@ -88,6 +89,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
// Check if org already has an active subscription (prevent duplicates)
|
||||
if (await hasActiveSubscription(organizationId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Organization already has an active subscription' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(subscription)
|
||||
.set({ referenceId: organizationId })
|
||||
|
||||
@@ -203,6 +203,10 @@ export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) =
|
||||
}
|
||||
|
||||
updateData.billingBlocked = body.billingBlocked
|
||||
// Clear the reason when unblocking
|
||||
if (body.billingBlocked === false) {
|
||||
updateData.billingBlockedReason = null
|
||||
}
|
||||
updated.push('billingBlocked')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { db, workflow as workflowTable } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
@@ -8,6 +6,7 @@ import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
|
||||
import { createSSECallbacks } from '@/lib/workflows/executor/execution-events'
|
||||
@@ -75,12 +74,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const { startBlockId, sourceSnapshot, input } = validation.data
|
||||
const executionId = uuidv4()
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId, userId: workflowTable.userId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
// Run preprocessing checks (billing, rate limits, usage limits)
|
||||
const preprocessResult = await preprocessExecution({
|
||||
workflowId,
|
||||
userId,
|
||||
triggerType: 'manual',
|
||||
executionId,
|
||||
requestId,
|
||||
checkRateLimit: false, // Manual executions don't rate limit
|
||||
checkDeployment: false, // Run-from-block doesn't require deployment
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
const { error } = preprocessResult
|
||||
logger.warn(`[${requestId}] Preprocessing failed for run-from-block`, {
|
||||
workflowId,
|
||||
error: error?.message,
|
||||
statusCode: error?.statusCode,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'Execution blocked' },
|
||||
{ status: error?.statusCode || 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const workflowRecord = preprocessResult.workflowRecord
|
||||
if (!workflowRecord?.workspaceId) {
|
||||
return NextResponse.json({ error: 'Workflow not found or has no workspace' }, { status: 404 })
|
||||
}
|
||||
@@ -92,6 +110,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
workflowId,
|
||||
startBlockId,
|
||||
executedBlocksCount: sourceSnapshot.executedBlocks.length,
|
||||
billingActorUserId: preprocessResult.actorUserId,
|
||||
})
|
||||
|
||||
const loggingSession = new LoggingSession(workflowId, executionId, 'manual', requestId)
|
||||
|
||||
@@ -294,13 +294,6 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
|
||||
'user-follow-modify': 'Follow and unfollow artists and users',
|
||||
'user-read-playback-position': 'View playback position in podcasts',
|
||||
'ugc-image-upload': 'Upload images to Spotify playlists',
|
||||
// TikTok scopes
|
||||
'user.info.basic': 'View basic profile info (avatar, display name)',
|
||||
'user.info.profile': 'View profile details (bio, verified status)',
|
||||
'user.info.stats': 'View account statistics (likes, followers, video count)',
|
||||
'video.list': 'View public videos',
|
||||
'video.publish': 'Post content to profile',
|
||||
'video.upload': 'Upload content as draft',
|
||||
}
|
||||
|
||||
function getScopeDescription(scope: string): string {
|
||||
@@ -380,13 +373,6 @@ export function OAuthRequiredModal({
|
||||
return
|
||||
}
|
||||
|
||||
if (providerId === 'tiktok') {
|
||||
onClose()
|
||||
const returnUrl = encodeURIComponent(window.location.href)
|
||||
window.location.href = `/api/auth/tiktok/authorize?returnUrl=${returnUrl}`
|
||||
return
|
||||
}
|
||||
|
||||
await client.oauth2.link({
|
||||
providerId,
|
||||
callbackURL: window.location.href,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type React from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { ChevronRight, Loader2, ServerIcon, WrenchIcon, XIcon } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Badge,
|
||||
@@ -111,18 +111,33 @@ interface ToolInputProps {
|
||||
* Represents a tool selected and configured in the workflow
|
||||
*
|
||||
* @remarks
|
||||
* Valid types include:
|
||||
* - Standard block types (e.g., 'api', 'search', 'function')
|
||||
* - 'custom-tool': User-defined tools with custom code
|
||||
* - 'mcp': Individual MCP tool from a connected server
|
||||
* - 'mcp-server': All tools from an MCP server (agent discovery mode).
|
||||
* At execution time, this expands into individual tool definitions for
|
||||
* all tools available on the server.
|
||||
*
|
||||
* For custom tools (new format), we only store: type, customToolId, usageControl, isExpanded.
|
||||
* Everything else (title, schema, code) is loaded dynamically from the database.
|
||||
* Legacy custom tools with inline schema/code are still supported for backwards compatibility.
|
||||
*/
|
||||
interface StoredTool {
|
||||
/** Block type identifier */
|
||||
/**
|
||||
* Block type identifier.
|
||||
* 'mcp-server' enables server-level selection where all tools from
|
||||
* the server are made available to the LLM at execution time.
|
||||
*/
|
||||
type: string
|
||||
/** Display title for the tool (optional for new custom tool format) */
|
||||
title?: string
|
||||
/** Direct tool ID for execution (optional for new custom tool format) */
|
||||
toolId?: string
|
||||
/** Parameter values configured by the user (optional for new custom tool format) */
|
||||
/**
|
||||
* Parameter values configured by the user.
|
||||
* For 'mcp-server' type, includes: serverId, serverUrl, serverName, toolCount
|
||||
*/
|
||||
params?: Record<string, string>
|
||||
/** Whether the tool details are expanded in UI */
|
||||
isExpanded?: boolean
|
||||
@@ -1007,6 +1022,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState<number | null>(null)
|
||||
const [expandedMcpServers, setExpandedMcpServers] = useState<Set<string>>(new Set())
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
|
||||
@@ -1236,6 +1252,18 @@ export const ToolInput = memo(function ToolInput({
|
||||
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an MCP server is already selected (all tools mode).
|
||||
*
|
||||
* @param serverId - The MCP server identifier to check
|
||||
* @returns `true` if the MCP server is already selected
|
||||
*/
|
||||
const isMcpServerAlreadySelected = (serverId: string): boolean => {
|
||||
return selectedTools.some(
|
||||
(tool) => tool.type === 'mcp-server' && tool.params?.serverId === serverId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a custom tool is already selected.
|
||||
*
|
||||
@@ -1260,6 +1288,37 @@ export const ToolInput = memo(function ToolInput({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups MCP tools by their parent server.
|
||||
*
|
||||
* @returns Map of serverId to array of tools
|
||||
*/
|
||||
const mcpToolsByServer = useMemo(() => {
|
||||
const grouped = new Map<string, typeof availableMcpTools>()
|
||||
for (const tool of availableMcpTools) {
|
||||
if (!grouped.has(tool.serverId)) {
|
||||
grouped.set(tool.serverId, [])
|
||||
}
|
||||
grouped.get(tool.serverId)!.push(tool)
|
||||
}
|
||||
return grouped
|
||||
}, [availableMcpTools])
|
||||
|
||||
/**
|
||||
* Toggles the expanded state of an MCP server in the dropdown.
|
||||
*/
|
||||
const toggleMcpServerExpanded = useCallback((serverId: string) => {
|
||||
setExpandedMcpServers((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(serverId)) {
|
||||
next.delete(serverId)
|
||||
} else {
|
||||
next.add(serverId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Checks if a block supports multiple operations.
|
||||
*
|
||||
@@ -1805,41 +1864,125 @@ export const ToolInput = memo(function ToolInput({
|
||||
})
|
||||
}
|
||||
|
||||
// MCP Tools section
|
||||
if (!permissionConfig.disableMcpTools && availableMcpTools.length > 0) {
|
||||
groups.push({
|
||||
section: 'MCP Tools',
|
||||
items: availableMcpTools.map((mcpTool) => {
|
||||
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
|
||||
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id)
|
||||
return {
|
||||
label: mcpTool.name,
|
||||
value: `mcp-${mcpTool.id}`,
|
||||
iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon),
|
||||
// MCP Servers section - grouped by server with expandable folders
|
||||
if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) {
|
||||
// Create items for each server (as expandable folders)
|
||||
const serverItems: ComboboxOption[] = []
|
||||
|
||||
for (const [serverId, tools] of mcpToolsByServer) {
|
||||
const server = mcpServers.find((s) => s.id === serverId)
|
||||
const serverName = tools[0]?.serverName || server?.name || 'Unknown Server'
|
||||
const isExpanded = expandedMcpServers.has(serverId)
|
||||
const serverAlreadySelected = isMcpServerAlreadySelected(serverId)
|
||||
const toolCount = tools.length
|
||||
|
||||
// Server folder header (clickable to expand/collapse)
|
||||
serverItems.push({
|
||||
label: serverName,
|
||||
value: `mcp-server-folder-${serverId}`,
|
||||
iconElement: (
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-[12px] w-[12px] text-[var(--text-tertiary)] transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||
style={{ background: '#6366F1' }}
|
||||
>
|
||||
<ServerIcon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onSelect: () => {
|
||||
toggleMcpServerExpanded(serverId)
|
||||
},
|
||||
disabled: false,
|
||||
keepOpen: true, // Keep dropdown open when toggling folder expansion
|
||||
})
|
||||
|
||||
// If expanded, show "Use all tools" option and individual tools
|
||||
if (isExpanded) {
|
||||
// "Use all tools from server" option
|
||||
serverItems.push({
|
||||
label: `Use all ${toolCount} tools`,
|
||||
value: `mcp-server-all-${serverId}`,
|
||||
iconElement: (
|
||||
<div className='ml-[20px] flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px] bg-[#6366F1]'>
|
||||
<McpIcon className='h-[10px] w-[10px] text-white' />
|
||||
</div>
|
||||
),
|
||||
onSelect: () => {
|
||||
if (alreadySelected) return
|
||||
if (serverAlreadySelected) return
|
||||
// Remove any individual tools from this server that were previously selected
|
||||
const filteredTools = selectedTools.filter(
|
||||
(tool) => !(tool.type === 'mcp' && tool.params?.serverId === serverId)
|
||||
)
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
type: 'mcp-server',
|
||||
title: `${serverName} (all tools)`,
|
||||
toolId: `mcp-server-${serverId}`,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
serverId,
|
||||
...(server?.url && { serverUrl: server.url }),
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
serverName,
|
||||
toolCount: String(toolCount),
|
||||
},
|
||||
isExpanded: true,
|
||||
isExpanded: false,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
handleMcpToolSelect(newTool, true)
|
||||
setStoreValue([
|
||||
...filteredTools.map((tool) => ({ ...tool, isExpanded: false })),
|
||||
newTool,
|
||||
])
|
||||
setOpen(false)
|
||||
},
|
||||
disabled: isPreview || disabled || alreadySelected,
|
||||
disabled: isPreview || disabled || serverAlreadySelected,
|
||||
})
|
||||
|
||||
// Individual tools from this server
|
||||
for (const mcpTool of tools) {
|
||||
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id) || serverAlreadySelected
|
||||
serverItems.push({
|
||||
label: mcpTool.name,
|
||||
value: `mcp-${mcpTool.id}`,
|
||||
iconElement: (
|
||||
<div className='ml-[20px]'>
|
||||
{createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon)}
|
||||
</div>
|
||||
),
|
||||
onSelect: () => {
|
||||
if (alreadySelected) return
|
||||
const newTool: StoredTool = {
|
||||
type: 'mcp',
|
||||
title: mcpTool.name,
|
||||
toolId: mcpTool.id,
|
||||
params: {
|
||||
serverId: mcpTool.serverId,
|
||||
...(server?.url && { serverUrl: server.url }),
|
||||
toolName: mcpTool.name,
|
||||
serverName: mcpTool.serverName,
|
||||
},
|
||||
isExpanded: true,
|
||||
usageControl: 'auto',
|
||||
schema: {
|
||||
...mcpTool.inputSchema,
|
||||
description: mcpTool.description,
|
||||
},
|
||||
}
|
||||
handleMcpToolSelect(newTool, true)
|
||||
},
|
||||
disabled: isPreview || disabled || alreadySelected,
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
groups.push({
|
||||
section: 'MCP Servers',
|
||||
items: serverItems,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1922,6 +2065,8 @@ export const ToolInput = memo(function ToolInput({
|
||||
customTools,
|
||||
availableMcpTools,
|
||||
mcpServers,
|
||||
mcpToolsByServer,
|
||||
expandedMcpServers,
|
||||
toolBlocks,
|
||||
isPreview,
|
||||
disabled,
|
||||
@@ -1935,8 +2080,10 @@ export const ToolInput = memo(function ToolInput({
|
||||
getToolIdForOperation,
|
||||
isToolAlreadySelected,
|
||||
isMcpToolAlreadySelected,
|
||||
isMcpServerAlreadySelected,
|
||||
isCustomToolAlreadySelected,
|
||||
isWorkflowAlreadySelected,
|
||||
toggleMcpServerExpanded,
|
||||
])
|
||||
|
||||
const toolRequiresOAuth = (toolId: string): boolean => {
|
||||
@@ -2363,24 +2510,25 @@ export const ToolInput = memo(function ToolInput({
|
||||
{/* Selected Tools List */}
|
||||
{selectedTools.length > 0 &&
|
||||
selectedTools.map((tool, toolIndex) => {
|
||||
// Handle custom tools, MCP tools, and workflow tools differently
|
||||
// Handle custom tools, MCP tools, MCP servers, and workflow tools differently
|
||||
const isCustomTool = tool.type === 'custom-tool'
|
||||
const isMcpTool = tool.type === 'mcp'
|
||||
const isMcpServer = tool.type === 'mcp-server'
|
||||
const isWorkflowTool = tool.type === 'workflow'
|
||||
const toolBlock =
|
||||
!isCustomTool && !isMcpTool
|
||||
!isCustomTool && !isMcpTool && !isMcpServer
|
||||
? toolBlocks.find((block) => block.type === tool.type)
|
||||
: null
|
||||
|
||||
// Get the current tool ID (may change based on operation)
|
||||
const currentToolId =
|
||||
!isCustomTool && !isMcpTool
|
||||
!isCustomTool && !isMcpTool && !isMcpServer
|
||||
? getToolIdForOperation(tool.type, tool.operation) || tool.toolId || ''
|
||||
: tool.toolId || ''
|
||||
|
||||
// Get tool parameters using the new utility with block type for UI components
|
||||
const toolParams =
|
||||
!isCustomTool && !isMcpTool && currentToolId
|
||||
!isCustomTool && !isMcpTool && !isMcpServer && currentToolId
|
||||
? getToolParametersConfig(currentToolId, tool.type, {
|
||||
operation: tool.operation,
|
||||
...tool.params,
|
||||
@@ -2449,21 +2597,32 @@ export const ToolInput = memo(function ToolInput({
|
||||
? customToolParams
|
||||
: isMcpTool
|
||||
? mcpToolParams
|
||||
: toolParams?.userInputParameters || []
|
||||
: isMcpServer
|
||||
? [] // MCP servers have no user-configurable params
|
||||
: toolParams?.userInputParameters || []
|
||||
|
||||
// Check if tool requires OAuth
|
||||
const requiresOAuth =
|
||||
!isCustomTool && !isMcpTool && currentToolId && toolRequiresOAuth(currentToolId)
|
||||
!isCustomTool &&
|
||||
!isMcpTool &&
|
||||
!isMcpServer &&
|
||||
currentToolId &&
|
||||
toolRequiresOAuth(currentToolId)
|
||||
const oauthConfig =
|
||||
!isCustomTool && !isMcpTool && currentToolId ? getToolOAuthConfig(currentToolId) : null
|
||||
!isCustomTool && !isMcpTool && !isMcpServer && currentToolId
|
||||
? getToolOAuthConfig(currentToolId)
|
||||
: null
|
||||
|
||||
// Determine if tool has expandable body content
|
||||
const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(tool.type)
|
||||
const hasOperations =
|
||||
!isCustomTool && !isMcpTool && !isMcpServer && hasMultipleOperations(tool.type)
|
||||
const filteredDisplayParams = displayParams.filter((param) =>
|
||||
evaluateParameterCondition(param, tool)
|
||||
)
|
||||
const hasToolBody =
|
||||
hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0
|
||||
// MCP servers are expandable to show tool list
|
||||
const hasToolBody = isMcpServer
|
||||
? true
|
||||
: hasOperations || (requiresOAuth && oauthConfig) || filteredDisplayParams.length > 0
|
||||
|
||||
// Only show expansion if tool has body content
|
||||
const isExpandedForDisplay = hasToolBody
|
||||
@@ -2472,6 +2631,11 @@ export const ToolInput = memo(function ToolInput({
|
||||
: !!tool.isExpanded
|
||||
: false
|
||||
|
||||
// For MCP servers, get the list of tools for display
|
||||
const mcpServerTools = isMcpServer
|
||||
? availableMcpTools.filter((t) => t.serverId === tool.params?.serverId)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${tool.customToolId || tool.toolId || toolIndex}-${toolIndex}`}
|
||||
@@ -2508,7 +2672,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
style={{
|
||||
backgroundColor: isCustomTool
|
||||
? '#3B82F6'
|
||||
: isMcpTool
|
||||
: isMcpTool || isMcpServer
|
||||
? mcpTool?.bgColor || '#6366F1'
|
||||
: isWorkflowTool
|
||||
? '#6366F1'
|
||||
@@ -2519,6 +2683,8 @@ export const ToolInput = memo(function ToolInput({
|
||||
<WrenchIcon className='h-[10px] w-[10px] text-white' />
|
||||
) : isMcpTool ? (
|
||||
<IconComponent icon={McpIcon} className='h-[10px] w-[10px] text-white' />
|
||||
) : isMcpServer ? (
|
||||
<ServerIcon className='h-[10px] w-[10px] text-white' />
|
||||
) : isWorkflowTool ? (
|
||||
<IconComponent icon={WorkflowIcon} className='h-[10px] w-[10px] text-white' />
|
||||
) : (
|
||||
@@ -2531,6 +2697,11 @@ export const ToolInput = memo(function ToolInput({
|
||||
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{isCustomTool ? customToolTitle : tool.title}
|
||||
</span>
|
||||
{isMcpServer && (
|
||||
<Badge variant='default' size='sm'>
|
||||
{tool.params?.toolCount || mcpServerTools.length} tools
|
||||
</Badge>
|
||||
)}
|
||||
{isMcpTool &&
|
||||
!mcpDataLoading &&
|
||||
(() => {
|
||||
@@ -2636,31 +2807,53 @@ export const ToolInput = memo(function ToolInput({
|
||||
|
||||
{!isCustomTool && isExpandedForDisplay && (
|
||||
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
{/* Operation dropdown for tools with multiple operations */}
|
||||
{(() => {
|
||||
const hasOperations = hasMultipleOperations(tool.type)
|
||||
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
|
||||
|
||||
return hasOperations && operationOptions.length > 0 ? (
|
||||
<div className='relative space-y-[6px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Operation
|
||||
</div>
|
||||
<Combobox
|
||||
options={operationOptions
|
||||
.filter((option) => option.id !== '')
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))}
|
||||
value={tool.operation || operationOptions[0].id}
|
||||
onChange={(value) => handleOperationChange(toolIndex, value)}
|
||||
placeholder='Select operation'
|
||||
disabled={disabled}
|
||||
/>
|
||||
{/* MCP Server tool list (read-only) */}
|
||||
{isMcpServer && mcpServerTools.length > 0 && (
|
||||
<div className='flex flex-col gap-[4px]'>
|
||||
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
Available tools:
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
<div className='flex flex-wrap gap-[4px]'>
|
||||
{mcpServerTools.map((serverTool) => (
|
||||
<Badge
|
||||
key={serverTool.id}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='text-[11px]'
|
||||
>
|
||||
{serverTool.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operation dropdown for tools with multiple operations */}
|
||||
{!isMcpServer &&
|
||||
(() => {
|
||||
const hasOperations = hasMultipleOperations(tool.type)
|
||||
const operationOptions = hasOperations ? getOperationOptions(tool.type) : []
|
||||
|
||||
return hasOperations && operationOptions.length > 0 ? (
|
||||
<div className='relative space-y-[6px]'>
|
||||
<div className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Operation
|
||||
</div>
|
||||
<Combobox
|
||||
options={operationOptions
|
||||
.filter((option) => option.id !== '')
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.id,
|
||||
}))}
|
||||
value={tool.operation || operationOptions[0].id}
|
||||
onChange={(value) => handleOperationChange(toolIndex, value)}
|
||||
placeholder='Select operation'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* OAuth credential selector if required */}
|
||||
{requiresOAuth && oauthConfig && (
|
||||
|
||||
@@ -50,6 +50,12 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
/** Stable empty object to avoid creating new references */
|
||||
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
|
||||
|
||||
/** Shared style for dashed divider lines */
|
||||
const DASHED_DIVIDER_STYLE = {
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Icon component for rendering block icons.
|
||||
*
|
||||
@@ -89,31 +95,23 @@ export function Editor() {
|
||||
const blockConfig = currentBlock ? getBlock(currentBlock.type) : null
|
||||
const title = currentBlock?.name || 'Editor'
|
||||
|
||||
// Check if selected block is a subflow (loop or parallel)
|
||||
const isSubflow =
|
||||
currentBlock && (currentBlock.type === 'loop' || currentBlock.type === 'parallel')
|
||||
|
||||
// Get subflow display properties from configs
|
||||
const subflowConfig = isSubflow ? (currentBlock.type === 'loop' ? LoopTool : ParallelTool) : null
|
||||
|
||||
// Check if selected block is a workflow block
|
||||
const isWorkflowBlock =
|
||||
currentBlock && (currentBlock.type === 'workflow' || currentBlock.type === 'workflow_input')
|
||||
|
||||
// Get workspace ID from params
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// Refs for resize functionality
|
||||
const subBlocksRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Get user permissions
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Get active workflow ID
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
|
||||
// Get block properties (advanced/trigger modes)
|
||||
const { advancedMode, triggerMode } = useEditorBlockProperties(
|
||||
currentBlockId,
|
||||
currentWorkflow.isSnapshotView
|
||||
@@ -145,10 +143,9 @@ export function Editor() {
|
||||
[subBlocksForCanonical]
|
||||
)
|
||||
const canonicalModeOverrides = currentBlock?.data?.canonicalModes
|
||||
const advancedValuesPresent = hasAdvancedValues(
|
||||
subBlocksForCanonical,
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
const advancedValuesPresent = useMemo(
|
||||
() => hasAdvancedValues(subBlocksForCanonical, blockSubBlockValues, canonicalIndex),
|
||||
[subBlocksForCanonical, blockSubBlockValues, canonicalIndex]
|
||||
)
|
||||
const displayAdvancedOptions = userPermissions.canEdit
|
||||
? advancedMode
|
||||
@@ -156,11 +153,9 @@ export function Editor() {
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(() => {
|
||||
for (const subBlock of subBlocksForCanonical) {
|
||||
// Must be standalone advanced (mode: 'advanced' without canonicalParamId)
|
||||
if (subBlock.mode !== 'advanced') continue
|
||||
if (canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) continue
|
||||
|
||||
// Check condition - skip if condition not met for current values
|
||||
if (
|
||||
subBlock.condition &&
|
||||
!evaluateSubBlockCondition(subBlock.condition, blockSubBlockValues)
|
||||
@@ -173,7 +168,6 @@ export function Editor() {
|
||||
return false
|
||||
}, [subBlocksForCanonical, canonicalIndex.canonicalIdBySubBlockId, blockSubBlockValues])
|
||||
|
||||
// Get subblock layout using custom hook
|
||||
const { subBlocks, stateToUse: subBlockState } = useEditorSubblockLayout(
|
||||
blockConfig || ({} as any),
|
||||
currentBlockId || '',
|
||||
@@ -206,31 +200,34 @@ export function Editor() {
|
||||
return { regularSubBlocks: regular, advancedOnlySubBlocks: advancedOnly }
|
||||
}, [subBlocks, canonicalIndex.canonicalIdBySubBlockId])
|
||||
|
||||
// Get block connections
|
||||
const { incomingConnections, hasIncomingConnections } = useBlockConnections(currentBlockId || '')
|
||||
|
||||
// Connections resize hook
|
||||
const { handleMouseDown: handleConnectionsResizeMouseDown, isResizing } = useConnectionsResize({
|
||||
subBlocksRef,
|
||||
})
|
||||
|
||||
// Collaborative actions
|
||||
const {
|
||||
collaborativeSetBlockCanonicalMode,
|
||||
collaborativeUpdateBlockName,
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Advanced mode toggle handler
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (!currentBlockId || !userPermissions.canEdit) return
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
// Rename state
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const nameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/**
|
||||
* Ref callback that auto-selects the input text when mounted.
|
||||
*/
|
||||
const nameInputRefCallback = useCallback((element: HTMLInputElement | null) => {
|
||||
if (element) {
|
||||
element.select()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handles starting the rename process.
|
||||
@@ -251,7 +248,6 @@ export function Editor() {
|
||||
if (trimmedName && trimmedName !== currentBlock?.name) {
|
||||
const result = collaborativeUpdateBlockName(currentBlockId, trimmedName)
|
||||
if (!result.success) {
|
||||
// Keep rename mode open on error so user can correct the name
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -266,14 +262,6 @@ export function Editor() {
|
||||
setEditedName('')
|
||||
}, [])
|
||||
|
||||
// Focus input when entering rename mode
|
||||
useEffect(() => {
|
||||
if (isRenaming && nameInputRef.current) {
|
||||
nameInputRef.current.select()
|
||||
}
|
||||
}, [isRenaming])
|
||||
|
||||
// Trigger rename mode when signaled from context menu
|
||||
useEffect(() => {
|
||||
if (shouldFocusRename && currentBlock) {
|
||||
handleStartRename()
|
||||
@@ -284,17 +272,13 @@ export function Editor() {
|
||||
/**
|
||||
* Handles opening documentation link in a new secure tab.
|
||||
*/
|
||||
const handleOpenDocs = () => {
|
||||
const handleOpenDocs = useCallback(() => {
|
||||
const docsLink = isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink
|
||||
if (docsLink) {
|
||||
window.open(docsLink, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
window.open(docsLink || 'https://docs.sim.ai/quick-reference', '_blank', 'noopener,noreferrer')
|
||||
}, [isSubflow, subflowConfig?.docsLink, blockConfig?.docsLink])
|
||||
|
||||
// Get child workflow ID for workflow blocks
|
||||
const childWorkflowId = isWorkflowBlock ? blockSubBlockValues?.workflowId : null
|
||||
|
||||
// Fetch child workflow state for preview (only for workflow blocks with a selected workflow)
|
||||
const { data: childWorkflowState, isLoading: isLoadingChildWorkflow } =
|
||||
useWorkflowState(childWorkflowId)
|
||||
|
||||
@@ -307,7 +291,6 @@ export function Editor() {
|
||||
}
|
||||
}, [childWorkflowId, workspaceId])
|
||||
|
||||
// Determine if connections are at minimum height (collapsed state)
|
||||
const isConnectionsAtMinHeight = connectionsHeight <= 35
|
||||
|
||||
return (
|
||||
@@ -328,7 +311,7 @@ export function Editor() {
|
||||
)}
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
ref={nameInputRefCallback}
|
||||
type='text'
|
||||
value={editedName}
|
||||
onChange={(e) => setEditedName(e.target.value)}
|
||||
@@ -399,23 +382,21 @@ export function Editor() {
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)} */}
|
||||
{currentBlock && (isSubflow ? subflowConfig?.docsLink : blockConfig?.docsLink) && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleOpenDocs}
|
||||
aria-label='Open documentation'
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Open docs</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={handleOpenDocs}
|
||||
aria-label='Open documentation'
|
||||
>
|
||||
<BookOpen className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Open docs</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -495,13 +476,7 @@ export function Editor() {
|
||||
</div>
|
||||
</div>
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -566,13 +541,7 @@ export function Editor() {
|
||||
/>
|
||||
{showDivider && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -581,13 +550,7 @@ export function Editor() {
|
||||
|
||||
{hasAdvancedOnlyFields && userPermissions.canEdit && (
|
||||
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||
<div
|
||||
className='h-[1.25px] flex-1'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleToggleAdvancedMode}
|
||||
@@ -600,13 +563,7 @@ export function Editor() {
|
||||
className={`h-[14px] w-[14px] transition-transform duration-200 ${displayAdvancedOptions ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className='h-[1.25px] flex-1'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -630,13 +587,7 @@ export function Editor() {
|
||||
/>
|
||||
{index < advancedOnlySubBlocks.length - 1 && (
|
||||
<div className='subblock-divider px-[2px] pt-[16px] pb-[13px]'>
|
||||
<div
|
||||
className='h-[1.25px]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)',
|
||||
}}
|
||||
/>
|
||||
<div className='h-[1.25px]' style={DASHED_DIVIDER_STYLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import { TikTokIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { AuthMode } from '@/blocks/types'
|
||||
import type { TikTokResponse } from '@/tools/tiktok/types'
|
||||
|
||||
export const TikTokBlock: BlockConfig<TikTokResponse> = {
|
||||
type: 'tiktok',
|
||||
name: 'TikTok',
|
||||
description: 'Access TikTok user profiles, videos, and publish content',
|
||||
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 from public URLs.',
|
||||
docsLink: 'https://docs.sim.ai/tools/tiktok',
|
||||
category: 'tools',
|
||||
bgColor: '#000000',
|
||||
icon: TikTokIcon,
|
||||
subBlocks: [
|
||||
// Operation selection
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Get User Info', id: 'get_user' },
|
||||
{ label: 'List Videos', id: 'list_videos' },
|
||||
{ label: 'Query Videos', id: 'query_videos' },
|
||||
{ label: 'Query Creator Info', id: 'query_creator_info' },
|
||||
{ label: 'Direct Post Video', id: 'direct_post_video' },
|
||||
{ label: 'Get Post Status', id: 'get_post_status' },
|
||||
],
|
||||
value: () => 'get_user',
|
||||
},
|
||||
|
||||
// TikTok OAuth Authentication
|
||||
{
|
||||
id: 'credential',
|
||||
title: 'TikTok Account',
|
||||
type: 'oauth-input',
|
||||
serviceId: 'tiktok',
|
||||
placeholder: 'Select TikTok account',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Get User Info specific fields
|
||||
{
|
||||
id: 'fields',
|
||||
title: 'Fields',
|
||||
type: 'short-input',
|
||||
placeholder: 'open_id,display_name,avatar_url,follower_count,video_count',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_user',
|
||||
},
|
||||
},
|
||||
|
||||
// List Videos specific fields
|
||||
{
|
||||
id: 'maxCount',
|
||||
title: 'Max Count',
|
||||
type: 'short-input',
|
||||
placeholder: '20',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'list_videos',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
title: 'Cursor',
|
||||
type: 'short-input',
|
||||
placeholder: 'Pagination cursor from previous response',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'list_videos',
|
||||
},
|
||||
},
|
||||
|
||||
// Query Videos specific fields
|
||||
{
|
||||
id: 'videoIds',
|
||||
title: 'Video IDs',
|
||||
type: 'long-input',
|
||||
placeholder: 'Comma-separated video IDs (e.g., 7077642457847994444,7080217258529732386)',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'query_videos',
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'query_videos',
|
||||
},
|
||||
},
|
||||
|
||||
// Direct Post Video specific fields
|
||||
{
|
||||
id: 'videoUrl',
|
||||
title: 'Video URL',
|
||||
type: 'short-input',
|
||||
placeholder: 'https://example.com/video.mp4',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'direct_post_video',
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'direct_post_video',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
title: 'Caption',
|
||||
type: 'long-input',
|
||||
placeholder: 'Video caption with #hashtags and @mentions',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: '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' },
|
||||
],
|
||||
value: () => 'PUBLIC_TO_EVERYONE',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'direct_post_video',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'disableComment',
|
||||
title: 'Disable Comments',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'direct_post_video',
|
||||
},
|
||||
},
|
||||
|
||||
// Get Post Status specific fields
|
||||
{
|
||||
id: 'publishId',
|
||||
title: 'Publish ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'v_pub_file~v2-1.123456789',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'get_post_status',
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'get_post_status',
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'tiktok_get_user',
|
||||
'tiktok_list_videos',
|
||||
'tiktok_query_videos',
|
||||
'tiktok_query_creator_info',
|
||||
'tiktok_direct_post_video',
|
||||
'tiktok_get_post_status',
|
||||
],
|
||||
config: {
|
||||
tool: (inputs) => {
|
||||
const operation = inputs.operation || 'get_user'
|
||||
|
||||
switch (operation) {
|
||||
case 'list_videos':
|
||||
return 'tiktok_list_videos'
|
||||
case 'query_videos':
|
||||
return 'tiktok_query_videos'
|
||||
case 'query_creator_info':
|
||||
return 'tiktok_query_creator_info'
|
||||
case 'direct_post_video':
|
||||
return 'tiktok_direct_post_video'
|
||||
case 'get_post_status':
|
||||
return 'tiktok_get_post_status'
|
||||
default:
|
||||
return 'tiktok_get_user'
|
||||
}
|
||||
},
|
||||
params: (inputs) => {
|
||||
const operation = inputs.operation || 'get_user'
|
||||
const { credential } = inputs
|
||||
|
||||
switch (operation) {
|
||||
case 'get_user':
|
||||
return {
|
||||
accessToken: credential,
|
||||
...(inputs.fields && { fields: inputs.fields }),
|
||||
}
|
||||
case 'list_videos':
|
||||
return {
|
||||
accessToken: credential,
|
||||
...(inputs.maxCount && { maxCount: Number(inputs.maxCount) }),
|
||||
...(inputs.cursor && { cursor: Number(inputs.cursor) }),
|
||||
}
|
||||
case 'query_videos':
|
||||
return {
|
||||
accessToken: credential,
|
||||
videoIds: inputs.videoIds
|
||||
? inputs.videoIds.split(',').map((id: string) => id.trim())
|
||||
: [],
|
||||
}
|
||||
case 'query_creator_info':
|
||||
return {
|
||||
accessToken: credential,
|
||||
}
|
||||
case 'direct_post_video':
|
||||
return {
|
||||
accessToken: credential,
|
||||
videoUrl: inputs.videoUrl || '',
|
||||
privacyLevel: inputs.privacyLevel || 'PUBLIC_TO_EVERYONE',
|
||||
...(inputs.title && { title: inputs.title }),
|
||||
...(inputs.disableComment === 'true' && { disableComment: true }),
|
||||
}
|
||||
case 'get_post_status':
|
||||
return {
|
||||
accessToken: credential,
|
||||
publishId: inputs.publishId || '',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
accessToken: credential,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'Operation to perform' },
|
||||
credential: { type: 'string', description: 'TikTok access token' },
|
||||
fields: { type: 'string', description: 'Comma-separated list of user fields to return' },
|
||||
maxCount: { type: 'number', description: 'Maximum number of videos to return (1-20)' },
|
||||
cursor: { type: 'number', description: 'Pagination cursor from previous response' },
|
||||
videoIds: { type: 'string', description: 'Comma-separated list of video IDs to query' },
|
||||
videoUrl: { type: 'string', description: 'Public URL of the video to post' },
|
||||
title: { type: 'string', description: 'Video caption/description' },
|
||||
privacyLevel: { type: 'string', description: 'Privacy level for the video' },
|
||||
disableComment: { type: 'string', description: 'Whether to disable comments' },
|
||||
publishId: { type: 'string', description: 'Publish ID to check status for' },
|
||||
},
|
||||
outputs: {
|
||||
// Get User outputs
|
||||
openId: { type: 'string', description: 'TikTok user ID' },
|
||||
displayName: { type: 'string', description: 'User display name' },
|
||||
avatarUrl: { type: 'string', description: 'Profile image URL' },
|
||||
bioDescription: { type: 'string', description: 'User bio' },
|
||||
followerCount: { type: 'number', description: 'Number of followers' },
|
||||
followingCount: { type: 'number', description: 'Number of accounts followed' },
|
||||
likesCount: { type: 'number', description: 'Total likes received' },
|
||||
videoCount: { type: 'number', description: 'Total public videos' },
|
||||
isVerified: { type: 'boolean', description: 'Whether account is verified' },
|
||||
// List/Query Videos outputs
|
||||
videos: { type: 'json', description: 'Array of video objects' },
|
||||
hasMore: { type: 'boolean', description: 'Whether more videos are available' },
|
||||
// Query Creator Info outputs
|
||||
creatorAvatarUrl: { type: 'string', description: 'Creator avatar URL' },
|
||||
creatorUsername: { type: 'string', description: 'Creator username' },
|
||||
creatorNickname: { type: 'string', description: 'Creator nickname' },
|
||||
privacyLevelOptions: { type: 'json', description: 'Available privacy levels for posting' },
|
||||
commentDisabled: { type: 'boolean', description: 'Whether comments are disabled by default' },
|
||||
duetDisabled: { type: 'boolean', description: 'Whether duets are disabled by default' },
|
||||
stitchDisabled: { type: 'boolean', description: 'Whether stitches are disabled by default' },
|
||||
maxVideoPostDurationSec: { type: 'number', description: 'Max video duration in seconds' },
|
||||
// Direct Post Video outputs
|
||||
publishId: { type: 'string', description: 'Publish ID for tracking post status' },
|
||||
// Get Post Status outputs
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Post status (PROCESSING_DOWNLOAD, PUBLISH_COMPLETE, FAILED)',
|
||||
},
|
||||
failReason: { type: 'string', description: 'Reason for failure if status is FAILED' },
|
||||
publiclyAvailablePostId: {
|
||||
type: 'json',
|
||||
description: 'Array of public post IDs when published',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -131,7 +131,6 @@ import { TavilyBlock } from '@/blocks/blocks/tavily'
|
||||
import { TelegramBlock } from '@/blocks/blocks/telegram'
|
||||
import { TextractBlock } from '@/blocks/blocks/textract'
|
||||
import { ThinkingBlock } from '@/blocks/blocks/thinking'
|
||||
import { TikTokBlock } from '@/blocks/blocks/tiktok'
|
||||
import { TinybirdBlock } from '@/blocks/blocks/tinybird'
|
||||
import { TranslateBlock } from '@/blocks/blocks/translate'
|
||||
import { TrelloBlock } from '@/blocks/blocks/trello'
|
||||
@@ -304,7 +303,6 @@ export const registry: Record<string, BlockConfig> = {
|
||||
supabase: SupabaseBlock,
|
||||
tavily: TavilyBlock,
|
||||
telegram: TelegramBlock,
|
||||
tiktok: TikTokBlock,
|
||||
textract: TextractBlock,
|
||||
thinking: ThinkingBlock,
|
||||
tinybird: TinybirdBlock,
|
||||
|
||||
@@ -52,6 +52,8 @@ export type ComboboxOption = {
|
||||
onSelect?: () => void
|
||||
/** Whether this option is disabled */
|
||||
disabled?: boolean
|
||||
/** When true, keep the dropdown open after selecting this option */
|
||||
keepOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,13 +254,15 @@ const Combobox = memo(
|
||||
* Handles selection of an option
|
||||
*/
|
||||
const handleSelect = useCallback(
|
||||
(selectedValue: string, customOnSelect?: () => void) => {
|
||||
(selectedValue: string, customOnSelect?: () => void, keepOpen?: boolean) => {
|
||||
// If option has custom onSelect, use it instead
|
||||
if (customOnSelect) {
|
||||
customOnSelect()
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
if (!keepOpen) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -270,11 +274,13 @@ const Combobox = memo(
|
||||
onMultiSelectChange(newValues)
|
||||
} else {
|
||||
onChange?.(selectedValue)
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
if (editable && inputRef.current) {
|
||||
inputRef.current.blur()
|
||||
if (!keepOpen) {
|
||||
setOpen(false)
|
||||
setHighlightedIndex(-1)
|
||||
setSearchQuery('')
|
||||
if (editable && inputRef.current) {
|
||||
inputRef.current.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -343,7 +349,7 @@ const Combobox = memo(
|
||||
e.preventDefault()
|
||||
const selectedOption = filteredOptions[highlightedIndex]
|
||||
if (selectedOption && !selectedOption.disabled) {
|
||||
handleSelect(selectedOption.value, selectedOption.onSelect)
|
||||
handleSelect(selectedOption.value, selectedOption.onSelect, selectedOption.keepOpen)
|
||||
}
|
||||
} else if (!editable) {
|
||||
e.preventDefault()
|
||||
@@ -668,7 +674,7 @@ const Combobox = memo(
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!option.disabled) {
|
||||
handleSelect(option.value, option.onSelect)
|
||||
handleSelect(option.value, option.onSelect, option.keepOpen)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
@@ -743,7 +749,7 @@ const Combobox = memo(
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!option.disabled) {
|
||||
handleSelect(option.value, option.onSelect)
|
||||
handleSelect(option.value, option.onSelect, option.keepOpen)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => !option.disabled && setHighlightedIndex(index)}
|
||||
|
||||
@@ -3472,14 +3472,6 @@ export function HumanInTheLoopIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function TikTokIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'>
|
||||
<path d='M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrelloIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -143,7 +143,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
private async validateToolPermissions(ctx: ExecutionContext, tools: ToolInput[]): Promise<void> {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return
|
||||
|
||||
const hasMcpTools = tools.some((t) => t.type === 'mcp')
|
||||
const hasMcpTools = tools.some((t) => t.type === 'mcp' || t.type === 'mcp-server')
|
||||
const hasCustomTools = tools.some((t) => t.type === 'custom-tool')
|
||||
|
||||
if (hasMcpTools) {
|
||||
@@ -161,7 +161,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
): Promise<ToolInput[]> {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return tools
|
||||
|
||||
const mcpTools = tools.filter((t) => t.type === 'mcp')
|
||||
const mcpTools = tools.filter((t) => t.type === 'mcp' || t.type === 'mcp-server')
|
||||
if (mcpTools.length === 0) return tools
|
||||
|
||||
const serverIds = [...new Set(mcpTools.map((t) => t.params?.serverId).filter(Boolean))]
|
||||
@@ -195,7 +195,7 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
}
|
||||
|
||||
return tools.filter((tool) => {
|
||||
if (tool.type !== 'mcp') return true
|
||||
if (tool.type !== 'mcp' && tool.type !== 'mcp-server') return true
|
||||
const serverId = tool.params?.serverId
|
||||
if (!serverId) return false
|
||||
return availableServerIds.has(serverId)
|
||||
@@ -211,11 +211,14 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
})
|
||||
|
||||
const mcpTools: ToolInput[] = []
|
||||
const mcpServers: ToolInput[] = []
|
||||
const otherTools: ToolInput[] = []
|
||||
|
||||
for (const tool of filtered) {
|
||||
if (tool.type === 'mcp') {
|
||||
mcpTools.push(tool)
|
||||
} else if (tool.type === 'mcp-server') {
|
||||
mcpServers.push(tool)
|
||||
} else {
|
||||
otherTools.push(tool)
|
||||
}
|
||||
@@ -224,7 +227,12 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
const otherResults = await Promise.all(
|
||||
otherTools.map(async (tool) => {
|
||||
try {
|
||||
if (tool.type && tool.type !== 'custom-tool' && tool.type !== 'mcp') {
|
||||
if (
|
||||
tool.type &&
|
||||
tool.type !== 'custom-tool' &&
|
||||
tool.type !== 'mcp' &&
|
||||
tool.type !== 'mcp-server'
|
||||
) {
|
||||
await validateBlockType(ctx.userId, tool.type, ctx)
|
||||
}
|
||||
if (tool.type === 'custom-tool' && (tool.schema || tool.customToolId)) {
|
||||
@@ -240,12 +248,133 @@ export class AgentBlockHandler implements BlockHandler {
|
||||
|
||||
const mcpResults = await this.processMcpToolsBatched(ctx, mcpTools)
|
||||
|
||||
const allTools = [...otherResults, ...mcpResults]
|
||||
// Process MCP servers (all tools from server mode)
|
||||
const mcpServerResults = await this.processMcpServerSelections(ctx, mcpServers)
|
||||
|
||||
const allTools = [...otherResults, ...mcpResults, ...mcpServerResults]
|
||||
return allTools.filter(
|
||||
(tool): tool is NonNullable<typeof tool> => tool !== null && tool !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process MCP server selections by discovering and formatting all tools from each server.
|
||||
* This enables "agent discovery" mode where the LLM can call any tool from the server.
|
||||
*/
|
||||
private async processMcpServerSelections(
|
||||
ctx: ExecutionContext,
|
||||
mcpServerSelections: ToolInput[]
|
||||
): Promise<any[]> {
|
||||
if (mcpServerSelections.length === 0) return []
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
for (const serverSelection of mcpServerSelections) {
|
||||
const serverId = serverSelection.params?.serverId
|
||||
const serverName = serverSelection.params?.serverName
|
||||
const usageControl = serverSelection.usageControl || 'auto'
|
||||
|
||||
if (!serverId) {
|
||||
logger.error('MCP server selection missing serverId:', serverSelection)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Discover all tools from this server
|
||||
const discoveredTools = await this.discoverMcpToolsForServer(ctx, serverId)
|
||||
|
||||
// Create tool definitions for each discovered tool
|
||||
for (const mcpTool of discoveredTools) {
|
||||
const created = await this.createMcpToolFromDiscoveredServerTool(
|
||||
ctx,
|
||||
mcpTool,
|
||||
serverId,
|
||||
serverName || serverId,
|
||||
usageControl
|
||||
)
|
||||
if (created) results.push(created)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[AgentHandler] Expanded MCP server ${serverName} into ${discoveredTools.length} tools`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[AgentHandler] Failed to process MCP server selection:`, { serverId, error })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an MCP tool from server discovery for the "all tools" mode.
|
||||
*/
|
||||
private async createMcpToolFromDiscoveredServerTool(
|
||||
ctx: ExecutionContext,
|
||||
mcpTool: any,
|
||||
serverId: string,
|
||||
serverName: string,
|
||||
usageControl: string
|
||||
): Promise<any> {
|
||||
const toolName = mcpTool.name
|
||||
|
||||
const { filterSchemaForLLM } = await import('@/tools/params')
|
||||
const filteredSchema = filterSchemaForLLM(
|
||||
mcpTool.inputSchema || { type: 'object', properties: {} },
|
||||
{}
|
||||
)
|
||||
|
||||
const toolId = createMcpToolId(serverId, toolName)
|
||||
|
||||
return {
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
description: mcpTool.description || `MCP tool ${toolName} from ${serverName}`,
|
||||
parameters: filteredSchema,
|
||||
params: {},
|
||||
usageControl,
|
||||
executeFunction: async (callParams: Record<string, any>) => {
|
||||
const headers = await buildAuthHeaders()
|
||||
const execUrl = buildAPIUrl('/api/mcp/tools/execute')
|
||||
|
||||
const execResponse = await fetch(execUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: stringifyJSON({
|
||||
serverId,
|
||||
toolName,
|
||||
arguments: callParams,
|
||||
workspaceId: ctx.workspaceId,
|
||||
workflowId: ctx.workflowId,
|
||||
toolSchema: mcpTool.inputSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!execResponse.ok) {
|
||||
throw new Error(
|
||||
`MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
const result = await execResponse.json()
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'MCP tool execution failed')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: result.data.output || {},
|
||||
metadata: {
|
||||
source: 'mcp-server',
|
||||
serverId,
|
||||
serverName,
|
||||
toolName,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async createCustomTool(ctx: ExecutionContext, tool: ToolInput): Promise<any> {
|
||||
const userProvidedParams = tool.params || {}
|
||||
|
||||
|
||||
@@ -29,11 +29,36 @@ export interface AgentInputs {
|
||||
verbosity?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a tool input for the agent block.
|
||||
*
|
||||
* @remarks
|
||||
* Valid types include:
|
||||
* - Standard block types (e.g., 'api', 'search', 'function')
|
||||
* - 'custom-tool': User-defined tools with custom code
|
||||
* - 'mcp': Individual MCP tool from a connected server
|
||||
* - 'mcp-server': All tools from an MCP server (agent discovery mode).
|
||||
* At execution time, this is expanded into individual tool definitions
|
||||
* for all tools available on the server. This enables dynamic capability
|
||||
* discovery where the LLM can call any tool from the server.
|
||||
*/
|
||||
export interface ToolInput {
|
||||
/**
|
||||
* Tool type identifier.
|
||||
* 'mcp-server' enables server-level selection where all tools from
|
||||
* the server are made available to the LLM at execution time.
|
||||
*/
|
||||
type?: string
|
||||
schema?: any
|
||||
title?: string
|
||||
code?: string
|
||||
/**
|
||||
* Tool parameters. For 'mcp-server' type, includes:
|
||||
* - serverId: The MCP server ID
|
||||
* - serverUrl: The server URL (optional)
|
||||
* - serverName: Human-readable server name
|
||||
* - toolCount: Number of tools available (for display)
|
||||
*/
|
||||
params?: Record<string, any>
|
||||
timeout?: number
|
||||
usageControl?: 'auto' | 'force' | 'none'
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
renderPasswordResetEmail,
|
||||
renderWelcomeEmail,
|
||||
} from '@/components/emails'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
|
||||
import { SSO_TRUSTED_PROVIDERS } from '@/lib/auth/sso/constants'
|
||||
import { sendPlanWelcomeEmail } from '@/lib/billing'
|
||||
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
|
||||
import { handleNewUser } from '@/lib/billing/core/usage'
|
||||
@@ -61,15 +59,12 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
|
||||
import { SSO_TRUSTED_PROVIDERS } from './sso/constants'
|
||||
|
||||
const logger = createLogger('Auth')
|
||||
|
||||
import {
|
||||
getMicrosoftRefreshTokenExpiry,
|
||||
getTikTokRefreshTokenExpiry,
|
||||
isMicrosoftProvider,
|
||||
isTikTokProvider,
|
||||
} from '@/lib/oauth/utils'
|
||||
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
|
||||
|
||||
const validStripeKey = env.STRIPE_SECRET_KEY
|
||||
|
||||
@@ -196,9 +191,7 @@ export const auth = betterAuth({
|
||||
|
||||
const refreshTokenExpiresAt = isMicrosoftProvider(account.providerId)
|
||||
? getMicrosoftRefreshTokenExpiry()
|
||||
: isTikTokProvider(account.providerId)
|
||||
? getTikTokRefreshTokenExpiry()
|
||||
: account.refreshTokenExpiresAt
|
||||
: account.refreshTokenExpiresAt
|
||||
|
||||
await db
|
||||
.update(schema.account)
|
||||
@@ -323,13 +316,6 @@ export const auth = betterAuth({
|
||||
.where(eq(schema.account.id, account.id))
|
||||
}
|
||||
|
||||
if (isTikTokProvider(account.providerId)) {
|
||||
await db
|
||||
.update(schema.account)
|
||||
.set({ refreshTokenExpiresAt: getTikTokRefreshTokenExpiry() })
|
||||
.where(eq(schema.account.id, account.id))
|
||||
}
|
||||
|
||||
// Sync webhooks for credential sets after connecting a new credential
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const userMemberships = await db
|
||||
@@ -2509,11 +2495,6 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
// TikTok provider - REMOVED from generic OAuth
|
||||
// TikTok uses non-standard OAuth (client_key instead of client_id)
|
||||
// and cannot work with the generic OAuth plugin.
|
||||
// TikTok OAuth is handled via custom routes at /api/auth/tiktok/*
|
||||
|
||||
// WordPress.com provider
|
||||
{
|
||||
providerId: 'wordpress',
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import { db } from '@sim/db'
|
||||
import * as schema from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
|
||||
const logger = createLogger('BillingAuthorization')
|
||||
|
||||
/**
|
||||
* Check if a user is authorized to manage billing for a given reference ID
|
||||
* Reference ID can be either a user ID (individual subscription) or organization ID (team subscription)
|
||||
*
|
||||
* This function also performs duplicate subscription validation for organizations:
|
||||
* - Rejects if an organization already has an active subscription (prevents duplicates)
|
||||
* - Personal subscriptions (referenceId === userId) skip this check to allow upgrades
|
||||
*/
|
||||
export async function authorizeSubscriptionReference(
|
||||
userId: string,
|
||||
referenceId: string
|
||||
): Promise<boolean> {
|
||||
// User can always manage their own subscriptions
|
||||
// User can always manage their own subscriptions (Pro upgrades, etc.)
|
||||
if (referenceId === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For organizations: check for existing active subscriptions to prevent duplicates
|
||||
if (await hasActiveSubscription(referenceId)) {
|
||||
logger.warn('Blocking checkout - active subscription already exists for organization', {
|
||||
userId,
|
||||
referenceId,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if referenceId is an organizationId the user has admin rights to
|
||||
const members = await db
|
||||
.select()
|
||||
|
||||
@@ -25,9 +25,11 @@ export function useSubscriptionUpgrade() {
|
||||
}
|
||||
|
||||
let currentSubscriptionId: string | undefined
|
||||
let allSubscriptions: any[] = []
|
||||
try {
|
||||
const listResult = await client.subscription.list()
|
||||
const activePersonalSub = listResult.data?.find(
|
||||
allSubscriptions = listResult.data || []
|
||||
const activePersonalSub = allSubscriptions.find(
|
||||
(sub: any) => sub.status === 'active' && sub.referenceId === userId
|
||||
)
|
||||
currentSubscriptionId = activePersonalSub?.id
|
||||
@@ -50,6 +52,25 @@ export function useSubscriptionUpgrade() {
|
||||
)
|
||||
|
||||
if (existingOrg) {
|
||||
// Check if this org already has an active team subscription
|
||||
const existingTeamSub = allSubscriptions.find(
|
||||
(sub: any) =>
|
||||
sub.status === 'active' &&
|
||||
sub.referenceId === existingOrg.id &&
|
||||
(sub.plan === 'team' || sub.plan === 'enterprise')
|
||||
)
|
||||
|
||||
if (existingTeamSub) {
|
||||
logger.warn('Organization already has an active team subscription', {
|
||||
userId,
|
||||
organizationId: existingOrg.id,
|
||||
existingSubscriptionId: existingTeamSub.id,
|
||||
})
|
||||
throw new Error(
|
||||
'This organization already has an active team subscription. Please manage it from the billing settings.'
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Using existing organization for team plan upgrade', {
|
||||
userId,
|
||||
organizationId: existingOrg.id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription } from '@sim/db/schema'
|
||||
import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { checkEnterprisePlan, checkProPlan, checkTeamPlan } from '@/lib/billing/subscriptions/utils'
|
||||
@@ -26,10 +26,22 @@ export async function getHighestPrioritySubscription(userId: string) {
|
||||
|
||||
let orgSubs: typeof personalSubs = []
|
||||
if (orgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(and(inArray(subscription.referenceId, orgIds), eq(subscription.status, 'active')))
|
||||
// Verify orgs exist to filter out orphaned subscriptions
|
||||
const existingOrgs = await db
|
||||
.select({ id: organization.id })
|
||||
.from(organization)
|
||||
.where(inArray(organization.id, orgIds))
|
||||
|
||||
const validOrgIds = existingOrgs.map((o) => o.id)
|
||||
|
||||
if (validOrgIds.length > 0) {
|
||||
orgSubs = await db
|
||||
.select()
|
||||
.from(subscription)
|
||||
.where(
|
||||
and(inArray(subscription.referenceId, validOrgIds), eq(subscription.status, 'active'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const allSubs = [...personalSubs, ...orgSubs]
|
||||
|
||||
@@ -25,6 +25,28 @@ const logger = createLogger('SubscriptionCore')
|
||||
|
||||
export { getHighestPrioritySubscription }
|
||||
|
||||
/**
|
||||
* Check if a referenceId (user ID or org ID) has an active subscription
|
||||
* Used for duplicate subscription prevention
|
||||
*
|
||||
* Fails closed: returns true on error to prevent duplicate creation
|
||||
*/
|
||||
export async function hasActiveSubscription(referenceId: string): Promise<boolean> {
|
||||
try {
|
||||
const [activeSub] = await db
|
||||
.select({ id: subscription.id })
|
||||
.from(subscription)
|
||||
.where(and(eq(subscription.referenceId, referenceId), eq(subscription.status, 'active')))
|
||||
.limit(1)
|
||||
|
||||
return !!activeSub
|
||||
} catch (error) {
|
||||
logger.error('Error checking active subscription', { error, referenceId })
|
||||
// Fail closed: assume subscription exists to prevent duplicate creation
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is on Pro plan (direct or via organization)
|
||||
*/
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
getHighestPrioritySubscription as getActiveSubscription,
|
||||
getUserSubscriptionState as getSubscriptionState,
|
||||
hasAccessControlAccess,
|
||||
hasActiveSubscription,
|
||||
hasCredentialSetsAccess,
|
||||
hasSSOAccess,
|
||||
isEnterpriseOrgAdminOrOwner,
|
||||
@@ -32,6 +33,11 @@ export {
|
||||
} from '@/lib/billing/core/usage'
|
||||
export * from '@/lib/billing/credits/balance'
|
||||
export * from '@/lib/billing/credits/purchase'
|
||||
export {
|
||||
blockOrgMembers,
|
||||
getOrgMemberIds,
|
||||
unblockOrgMembers,
|
||||
} from '@/lib/billing/organizations/membership'
|
||||
export * from '@/lib/billing/subscriptions/utils'
|
||||
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
|
||||
export * from '@/lib/billing/types'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { hasActiveSubscription } from '@/lib/billing'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
|
||||
@@ -159,6 +160,16 @@ export async function ensureOrganizationForTeamSubscription(
|
||||
if (existingMembership.length > 0) {
|
||||
const membership = existingMembership[0]
|
||||
if (membership.role === 'owner' || membership.role === 'admin') {
|
||||
// Check if org already has an active subscription (prevent duplicates)
|
||||
if (await hasActiveSubscription(membership.organizationId)) {
|
||||
logger.error('Organization already has an active subscription', {
|
||||
userId,
|
||||
organizationId: membership.organizationId,
|
||||
newSubscriptionId: subscription.id,
|
||||
})
|
||||
throw new Error('Organization already has an active subscription')
|
||||
}
|
||||
|
||||
logger.info('User already owns/admins an org, using it', {
|
||||
userId,
|
||||
organizationId: membership.organizationId,
|
||||
|
||||
@@ -15,13 +15,86 @@ import {
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||
|
||||
const logger = createLogger('OrganizationMembership')
|
||||
|
||||
export type BillingBlockReason = 'payment_failed' | 'dispute'
|
||||
|
||||
/**
|
||||
* Get all member user IDs for an organization
|
||||
*/
|
||||
export async function getOrgMemberIds(organizationId: string): Promise<string[]> {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
return members.map((m) => m.userId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Block all members of an organization for billing reasons
|
||||
* Returns the number of members actually blocked
|
||||
*
|
||||
* Reason priority: dispute > payment_failed
|
||||
* A payment_failed block won't overwrite an existing dispute block
|
||||
*/
|
||||
export async function blockOrgMembers(
|
||||
organizationId: string,
|
||||
reason: BillingBlockReason
|
||||
): Promise<number> {
|
||||
const memberIds = await getOrgMemberIds(organizationId)
|
||||
|
||||
if (memberIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Don't overwrite dispute blocks with payment_failed (dispute is higher priority)
|
||||
const whereClause =
|
||||
reason === 'payment_failed'
|
||||
? and(
|
||||
inArray(userStats.userId, memberIds),
|
||||
or(ne(userStats.billingBlockedReason, 'dispute'), isNull(userStats.billingBlockedReason))
|
||||
)
|
||||
: inArray(userStats.userId, memberIds)
|
||||
|
||||
const result = await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: reason })
|
||||
.where(whereClause)
|
||||
.returning({ userId: userStats.userId })
|
||||
|
||||
return result.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock all members of an organization blocked for a specific reason
|
||||
* Only unblocks members blocked for the specified reason (not other reasons)
|
||||
* Returns the number of members actually unblocked
|
||||
*/
|
||||
export async function unblockOrgMembers(
|
||||
organizationId: string,
|
||||
reason: BillingBlockReason
|
||||
): Promise<number> {
|
||||
const memberIds = await getOrgMemberIds(organizationId)
|
||||
|
||||
if (memberIds.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(and(inArray(userStats.userId, memberIds), eq(userStats.billingBlockedReason, reason)))
|
||||
.returning({ userId: userStats.userId })
|
||||
|
||||
return result.length
|
||||
}
|
||||
|
||||
export interface RestoreProResult {
|
||||
restored: boolean
|
||||
usageRestored: boolean
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { subscription, user, userStats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
|
||||
const logger = createLogger('DisputeWebhooks')
|
||||
@@ -57,36 +58,34 @@ export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
const memberCount = await blockOrgMembers(orgId, 'dispute')
|
||||
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
|
||||
logger.warn('Blocked org owner due to dispute', {
|
||||
if (memberCount > 0) {
|
||||
logger.warn('Blocked all org members due to dispute', {
|
||||
disputeId: dispute.id,
|
||||
ownerId: owners[0].userId,
|
||||
organizationId: orgId,
|
||||
memberCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles charge.dispute.closed - unblocks user if dispute was won
|
||||
* Handles charge.dispute.closed - unblocks user if dispute was won or warning closed
|
||||
*
|
||||
* Status meanings:
|
||||
* - 'won': Merchant won, customer's chargeback denied → unblock
|
||||
* - 'lost': Customer won, money refunded → stay blocked (they owe us)
|
||||
* - 'warning_closed': Pre-dispute inquiry closed without chargeback → unblock (false alarm)
|
||||
*/
|
||||
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
const dispute = event.data.object as Stripe.Dispute
|
||||
|
||||
if (dispute.status !== 'won') {
|
||||
logger.info('Dispute not won, user remains blocked', {
|
||||
// Only unblock if we won or the warning was closed without a full dispute
|
||||
const shouldUnblock = dispute.status === 'won' || dispute.status === 'warning_closed'
|
||||
|
||||
if (!shouldUnblock) {
|
||||
logger.info('Dispute resolved against us, user remains blocked', {
|
||||
disputeId: dispute.id,
|
||||
status: dispute.status,
|
||||
})
|
||||
@@ -98,7 +97,7 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock user (Pro plans)
|
||||
// Find and unblock user (Pro plans) - only if blocked for dispute, not other reasons
|
||||
const users = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
@@ -109,16 +108,17 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(eq(userStats.userId, users[0].id))
|
||||
.where(and(eq(userStats.userId, users[0].id), eq(userStats.billingBlockedReason, 'dispute')))
|
||||
|
||||
logger.info('Unblocked user after winning dispute', {
|
||||
logger.info('Unblocked user after dispute resolved in our favor', {
|
||||
disputeId: dispute.id,
|
||||
userId: users[0].id,
|
||||
status: dispute.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock org owner (Team/Enterprise)
|
||||
// Find and unblock all org members (Team/Enterprise) - consistent with payment success
|
||||
const subs = await db
|
||||
.select({ referenceId: subscription.referenceId })
|
||||
.from(subscription)
|
||||
@@ -127,24 +127,13 @@ export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
const memberCount = await unblockOrgMembers(orgId, 'dispute')
|
||||
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
|
||||
logger.info('Unblocked org owner after winning dispute', {
|
||||
disputeId: dispute.id,
|
||||
ownerId: owners[0].userId,
|
||||
organizationId: orgId,
|
||||
})
|
||||
}
|
||||
logger.info('Unblocked all org members after dispute resolved in our favor', {
|
||||
disputeId: dispute.id,
|
||||
organizationId: orgId,
|
||||
memberCount,
|
||||
status: dispute.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
userStats,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray, isNull, ne, or } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
|
||||
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||
import { blockOrgMembers, unblockOrgMembers } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
@@ -502,24 +503,7 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
}
|
||||
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
const memberIds = members.map((m) => m.userId)
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
// Only unblock users blocked for payment_failed, not disputes
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(
|
||||
and(
|
||||
inArray(userStats.userId, memberIds),
|
||||
eq(userStats.billingBlockedReason, 'payment_failed')
|
||||
)
|
||||
)
|
||||
}
|
||||
await unblockOrgMembers(sub.referenceId, 'payment_failed')
|
||||
} else {
|
||||
// Only unblock users blocked for payment_failed, not disputes
|
||||
await db
|
||||
@@ -616,28 +600,26 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
if (records.length > 0) {
|
||||
const sub = records[0]
|
||||
if (sub.plan === 'team' || sub.plan === 'enterprise') {
|
||||
const members = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, sub.referenceId))
|
||||
const memberIds = members.map((m) => m.userId)
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
}
|
||||
const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed')
|
||||
logger.info('Blocked team/enterprise members due to payment failure', {
|
||||
organizationId: sub.referenceId,
|
||||
memberCount: members.length,
|
||||
memberCount,
|
||||
isOverageInvoice,
|
||||
})
|
||||
} else {
|
||||
// Don't overwrite dispute blocks (dispute > payment_failed priority)
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.where(
|
||||
and(
|
||||
eq(userStats.userId, sub.referenceId),
|
||||
or(
|
||||
ne(userStats.billingBlockedReason, 'dispute'),
|
||||
isNull(userStats.billingBlockedReason)
|
||||
)
|
||||
)
|
||||
)
|
||||
logger.info('Blocked user due to payment failure', {
|
||||
userId: sub.referenceId,
|
||||
isOverageInvoice,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { member, organization, subscription } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { hasActiveSubscription } from '@/lib/billing/core/subscription'
|
||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||
import { restoreUserProSubscription } from '@/lib/billing/organizations/membership'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
@@ -52,14 +53,37 @@ async function restoreMemberProSubscriptions(organizationId: string): Promise<nu
|
||||
|
||||
/**
|
||||
* Cleanup organization when team/enterprise subscription is deleted.
|
||||
* - Checks if other active subscriptions point to this org (skip deletion if so)
|
||||
* - Restores member Pro subscriptions
|
||||
* - Deletes the organization
|
||||
* - Deletes the organization (only if no other active subs)
|
||||
* - Syncs usage limits for former members (resets to free or Pro tier)
|
||||
*/
|
||||
async function cleanupOrganizationSubscription(organizationId: string): Promise<{
|
||||
restoredProCount: number
|
||||
membersSynced: number
|
||||
organizationDeleted: boolean
|
||||
}> {
|
||||
// Check if other active subscriptions still point to this org
|
||||
// Note: The subscription being deleted is already marked as 'canceled' by better-auth
|
||||
// before this handler runs, so we only find truly active ones
|
||||
if (await hasActiveSubscription(organizationId)) {
|
||||
logger.info('Skipping organization deletion - other active subscriptions exist', {
|
||||
organizationId,
|
||||
})
|
||||
|
||||
// Still sync limits for members since this subscription was deleted
|
||||
const memberUserIds = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, organizationId))
|
||||
|
||||
for (const m of memberUserIds) {
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
}
|
||||
|
||||
return { restoredProCount: 0, membersSynced: memberUserIds.length, organizationDeleted: false }
|
||||
}
|
||||
|
||||
// Get member userIds before deletion (needed for limit syncing after org deletion)
|
||||
const memberUserIds = await db
|
||||
.select({ userId: member.userId })
|
||||
@@ -75,7 +99,7 @@ async function cleanupOrganizationSubscription(organizationId: string): Promise<
|
||||
await syncUsageLimitsFromSubscription(m.userId)
|
||||
}
|
||||
|
||||
return { restoredProCount, membersSynced: memberUserIds.length }
|
||||
return { restoredProCount, membersSynced: memberUserIds.length, organizationDeleted: true }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,15 +196,14 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
referenceId: subscription.referenceId,
|
||||
})
|
||||
|
||||
const { restoredProCount, membersSynced } = await cleanupOrganizationSubscription(
|
||||
subscription.referenceId
|
||||
)
|
||||
const { restoredProCount, membersSynced, organizationDeleted } =
|
||||
await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
|
||||
logger.info('Successfully processed enterprise subscription cancellation', {
|
||||
subscriptionId: subscription.id,
|
||||
stripeSubscriptionId,
|
||||
restoredProCount,
|
||||
organizationDeleted: true,
|
||||
organizationDeleted,
|
||||
membersSynced,
|
||||
})
|
||||
return
|
||||
@@ -297,7 +320,7 @@ export async function handleSubscriptionDeleted(subscription: {
|
||||
const cleanup = await cleanupOrganizationSubscription(subscription.referenceId)
|
||||
restoredProCount = cleanup.restoredProCount
|
||||
membersSynced = cleanup.membersSynced
|
||||
organizationDeleted = true
|
||||
organizationDeleted = cleanup.organizationDeleted
|
||||
} else if (subscription.plan === 'pro') {
|
||||
await syncUsageLimitsFromSubscription(subscription.referenceId)
|
||||
membersSynced = 1
|
||||
|
||||
@@ -244,8 +244,6 @@ export const env = createEnv({
|
||||
SPOTIFY_CLIENT_ID: z.string().optional(), // Spotify OAuth client ID
|
||||
SPOTIFY_CLIENT_SECRET: z.string().optional(), // Spotify OAuth client secret
|
||||
CALCOM_CLIENT_ID: z.string().optional(), // Cal.com OAuth client ID
|
||||
TIKTOK_CLIENT_ID: z.string().optional(), // TikTok OAuth client ID
|
||||
TIKTOK_CLIENT_SECRET: z.string().optional(), // TikTok OAuth client secret
|
||||
|
||||
// E2B Remote Code Execution
|
||||
E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
WorkflowExecutionSnapshot,
|
||||
WorkflowState,
|
||||
} from '@/lib/logs/types'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
|
||||
export interface ToolCall {
|
||||
name: string
|
||||
@@ -503,7 +504,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the workflow record to get the userId
|
||||
// Get the workflow record to get workspace and fallback userId
|
||||
const [workflowRecord] = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
@@ -515,7 +516,12 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
return
|
||||
}
|
||||
|
||||
const userId = workflowRecord.userId
|
||||
let billingUserId: string | null = null
|
||||
if (workflowRecord.workspaceId) {
|
||||
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
|
||||
}
|
||||
|
||||
const userId = billingUserId || workflowRecord.userId
|
||||
const costToStore = costSummary.totalCost
|
||||
|
||||
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './microsoft'
|
||||
export * from './oauth'
|
||||
export * from './types'
|
||||
export * from './utils'
|
||||
|
||||
19
apps/sim/lib/oauth/microsoft.ts
Normal file
19
apps/sim/lib/oauth/microsoft.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
|
||||
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
|
||||
|
||||
export const MICROSOFT_PROVIDERS = new Set([
|
||||
'microsoft-excel',
|
||||
'microsoft-planner',
|
||||
'microsoft-teams',
|
||||
'outlook',
|
||||
'onedrive',
|
||||
'sharepoint',
|
||||
])
|
||||
|
||||
export function isMicrosoftProvider(providerId: string): boolean {
|
||||
return MICROSOFT_PROVIDERS.has(providerId)
|
||||
}
|
||||
|
||||
export function getMicrosoftRefreshTokenExpiry(): Date {
|
||||
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
ShopifyIcon,
|
||||
SlackIcon,
|
||||
SpotifyIcon,
|
||||
TikTokIcon,
|
||||
TrelloIcon,
|
||||
VertexIcon,
|
||||
WealthboxIcon,
|
||||
@@ -797,27 +796,6 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
|
||||
},
|
||||
defaultService: 'spotify',
|
||||
},
|
||||
tiktok: {
|
||||
name: 'TikTok',
|
||||
icon: TikTokIcon,
|
||||
services: {
|
||||
tiktok: {
|
||||
name: 'TikTok',
|
||||
description: 'Access TikTok user profiles, videos, and publish content.',
|
||||
providerId: 'tiktok',
|
||||
icon: TikTokIcon,
|
||||
baseProviderIcon: TikTokIcon,
|
||||
scopes: [
|
||||
'user.info.basic',
|
||||
'user.info.profile',
|
||||
'user.info.stats',
|
||||
'video.list',
|
||||
'video.publish',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultService: 'tiktok',
|
||||
},
|
||||
}
|
||||
|
||||
interface ProviderAuthConfig {
|
||||
@@ -832,11 +810,6 @@ interface ProviderAuthConfig {
|
||||
* instead of in the request body. Used by Cal.com.
|
||||
*/
|
||||
refreshTokenInAuthHeader?: boolean
|
||||
/**
|
||||
* Custom parameter name for client ID in request body.
|
||||
* Defaults to 'client_id'. TikTok uses 'client_key'.
|
||||
*/
|
||||
clientIdParamName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1162,20 +1135,6 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
|
||||
supportsRefreshTokenRotation: false,
|
||||
}
|
||||
}
|
||||
case 'tiktok': {
|
||||
const { clientId, clientSecret } = getCredentials(
|
||||
env.TIKTOK_CLIENT_ID,
|
||||
env.TIKTOK_CLIENT_SECRET
|
||||
)
|
||||
return {
|
||||
tokenEndpoint: 'https://open.tiktokapis.com/v2/oauth/token/',
|
||||
clientId,
|
||||
clientSecret,
|
||||
useBasicAuth: false,
|
||||
supportsRefreshTokenRotation: true,
|
||||
clientIdParamName: 'client_key', // TikTok uses client_key instead of client_id
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
@@ -1212,9 +1171,7 @@ function buildAuthRequest(
|
||||
headers.Authorization = `Basic ${basicAuth}`
|
||||
} else {
|
||||
// Use body credentials - include client credentials in request body
|
||||
// Use custom param name if specified (e.g., TikTok uses 'client_key' instead of 'client_id')
|
||||
const clientIdParam = config.clientIdParamName || 'client_id'
|
||||
bodyParams[clientIdParam] = config.clientId
|
||||
bodyParams.client_id = config.clientId
|
||||
if (config.clientSecret) {
|
||||
bodyParams.client_secret = config.clientSecret
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ export type OAuthProvider =
|
||||
| 'wordpress'
|
||||
| 'spotify'
|
||||
| 'calcom'
|
||||
| 'tiktok'
|
||||
|
||||
export type OAuthService =
|
||||
| 'google'
|
||||
@@ -84,7 +83,6 @@ export type OAuthService =
|
||||
| 'wordpress'
|
||||
| 'spotify'
|
||||
| 'calcom'
|
||||
| 'tiktok'
|
||||
|
||||
export interface OAuthProviderConfig {
|
||||
name: string
|
||||
|
||||
@@ -7,49 +7,6 @@ import type {
|
||||
ScopeEvaluation,
|
||||
} from './types'
|
||||
|
||||
// =============================================================================
|
||||
// Refresh Token Configuration
|
||||
// =============================================================================
|
||||
|
||||
// Microsoft refresh token configuration (90 days)
|
||||
const MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS = 90
|
||||
export const PROACTIVE_REFRESH_THRESHOLD_DAYS = 7
|
||||
|
||||
const MICROSOFT_PROVIDERS = new Set([
|
||||
'microsoft-excel',
|
||||
'microsoft-planner',
|
||||
'microsoft-teams',
|
||||
'outlook',
|
||||
'onedrive',
|
||||
'sharepoint',
|
||||
])
|
||||
|
||||
export function isMicrosoftProvider(providerId: string): boolean {
|
||||
return MICROSOFT_PROVIDERS.has(providerId)
|
||||
}
|
||||
|
||||
export function getMicrosoftRefreshTokenExpiry(): Date {
|
||||
return new Date(Date.now() + MICROSOFT_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
// TikTok refresh token configuration (365 days)
|
||||
// TikTok access tokens expire in 24 hours, refresh tokens are valid for 365 days
|
||||
const TIKTOK_REFRESH_TOKEN_LIFETIME_DAYS = 365
|
||||
|
||||
const TIKTOK_PROVIDERS = new Set(['tiktok'])
|
||||
|
||||
export function isTikTokProvider(providerId: string): boolean {
|
||||
return TIKTOK_PROVIDERS.has(providerId)
|
||||
}
|
||||
|
||||
export function getTikTokRefreshTokenExpiry(): Date {
|
||||
return new Date(Date.now() + TIKTOK_REFRESH_TOKEN_LIFETIME_DAYS * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OAuth Service Utilities
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Returns a flat list of all available OAuth services with metadata.
|
||||
* This is safe to use on the server as it doesn't include React components.
|
||||
|
||||
@@ -1625,14 +1625,6 @@ import {
|
||||
} from '@/tools/telegram'
|
||||
import { textractParserTool } from '@/tools/textract'
|
||||
import { thinkingTool } from '@/tools/thinking'
|
||||
import {
|
||||
tiktokDirectPostVideoTool,
|
||||
tiktokGetPostStatusTool,
|
||||
tiktokGetUserTool,
|
||||
tiktokListVideosTool,
|
||||
tiktokQueryCreatorInfoTool,
|
||||
tiktokQueryVideosTool,
|
||||
} from '@/tools/tiktok'
|
||||
import { tinybirdEventsTool, tinybirdQueryTool } from '@/tools/tinybird'
|
||||
import {
|
||||
trelloAddCommentTool,
|
||||
@@ -2739,12 +2731,6 @@ export const tools: Record<string, ToolConfig> = {
|
||||
telegram_send_photo: telegramSendPhotoTool,
|
||||
telegram_send_video: telegramSendVideoTool,
|
||||
telegram_send_document: telegramSendDocumentTool,
|
||||
tiktok_get_user: tiktokGetUserTool,
|
||||
tiktok_list_videos: tiktokListVideosTool,
|
||||
tiktok_query_videos: tiktokQueryVideosTool,
|
||||
tiktok_query_creator_info: tiktokQueryCreatorInfoTool,
|
||||
tiktok_direct_post_video: tiktokDirectPostVideoTool,
|
||||
tiktok_get_post_status: tiktokGetPostStatusTool,
|
||||
clay_populate: clayPopulateTool,
|
||||
clerk_list_users: clerkListUsersTool,
|
||||
clerk_get_user: clerkGetUserTool,
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import type {
|
||||
TikTokDirectPostVideoParams,
|
||||
TikTokDirectPostVideoResponse,
|
||||
} from '@/tools/tiktok/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tiktokDirectPostVideoTool: ToolConfig<
|
||||
TikTokDirectPostVideoParams,
|
||||
TikTokDirectPostVideoResponse
|
||||
> = {
|
||||
id: 'tiktok_direct_post_video',
|
||||
name: 'TikTok Direct Post Video',
|
||||
description:
|
||||
'Publish a video to TikTok from a public URL. TikTok will fetch the video from the provided URL and post it to the authenticated user account. Rate limit: 6 requests per minute per user.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'tiktok',
|
||||
requiredScopes: ['video.publish'],
|
||||
},
|
||||
|
||||
params: {
|
||||
videoUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Public URL of the video to post. Must be accessible by TikTok servers.',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Video caption/description. Maximum 2200 characters.',
|
||||
},
|
||||
privacyLevel: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Privacy level for the video. Options: PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY. Note: Unaudited apps may be restricted to SELF_ONLY.',
|
||||
},
|
||||
disableDuet: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Disable duet for this video. Defaults to false.',
|
||||
},
|
||||
disableStitch: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Disable stitch for this video. Defaults to false.',
|
||||
},
|
||||
disableComment: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Disable comments for this video. Defaults to false.',
|
||||
},
|
||||
videoCoverTimestampMs: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Timestamp in milliseconds to use as the video cover image.',
|
||||
},
|
||||
isAigc: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Set to true if the video is AI-generated content (AIGC).',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () => 'https://open.tiktokapis.com/v2/post/publish/video/init/',
|
||||
method: 'POST',
|
||||
headers: (params: TikTokDirectPostVideoParams) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
}),
|
||||
body: (params: TikTokDirectPostVideoParams) => {
|
||||
const postInfo: Record<string, unknown> = {
|
||||
privacy_level: params.privacyLevel,
|
||||
}
|
||||
|
||||
if (params.title) {
|
||||
postInfo.title = params.title
|
||||
}
|
||||
if (params.disableDuet !== undefined) {
|
||||
postInfo.disable_duet = params.disableDuet
|
||||
}
|
||||
if (params.disableStitch !== undefined) {
|
||||
postInfo.disable_stitch = params.disableStitch
|
||||
}
|
||||
if (params.disableComment !== undefined) {
|
||||
postInfo.disable_comment = params.disableComment
|
||||
}
|
||||
if (params.videoCoverTimestampMs !== undefined) {
|
||||
postInfo.video_cover_timestamp_ms = params.videoCoverTimestampMs
|
||||
}
|
||||
if (params.isAigc !== undefined) {
|
||||
postInfo.is_aigc = params.isAigc
|
||||
}
|
||||
|
||||
return {
|
||||
post_info: postInfo,
|
||||
source_info: {
|
||||
source: 'PULL_FROM_URL',
|
||||
video_url: params.videoUrl,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<TikTokDirectPostVideoResponse> => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error?.code !== 'ok' && data.error?.code) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
publishId: '',
|
||||
},
|
||||
error: data.error?.message || 'Failed to initiate video post',
|
||||
}
|
||||
}
|
||||
|
||||
const publishId = data.data?.publish_id
|
||||
|
||||
if (!publishId) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
publishId: '',
|
||||
},
|
||||
error: 'No publish ID returned',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
publishId: publishId,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
publishId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Unique identifier for tracking the post status. Use this with the Get Post Status tool to check if the video was successfully published.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { TikTokGetPostStatusParams, TikTokGetPostStatusResponse } from '@/tools/tiktok/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tiktokGetPostStatusTool: ToolConfig<
|
||||
TikTokGetPostStatusParams,
|
||||
TikTokGetPostStatusResponse
|
||||
> = {
|
||||
id: 'tiktok_get_post_status',
|
||||
name: 'TikTok Get Post Status',
|
||||
description:
|
||||
'Check the status of a video post initiated with Direct Post Video. Use the publishId returned from the post request to track progress.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'tiktok',
|
||||
requiredScopes: ['video.publish'],
|
||||
},
|
||||
|
||||
params: {
|
||||
publishId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The publish ID returned from the Direct Post Video tool.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () => 'https://open.tiktokapis.com/v2/post/publish/status/fetch/',
|
||||
method: 'POST',
|
||||
headers: (params: TikTokGetPostStatusParams) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json; charset=UTF-8',
|
||||
}),
|
||||
body: (params: TikTokGetPostStatusParams) => ({
|
||||
publish_id: params.publishId,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<TikTokGetPostStatusResponse> => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error?.code !== 'ok' && data.error?.code) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
status: '',
|
||||
failReason: null,
|
||||
publiclyAvailablePostId: [],
|
||||
},
|
||||
error: data.error?.message || 'Failed to fetch post status',
|
||||
}
|
||||
}
|
||||
|
||||
const statusData = data.data
|
||||
|
||||
if (!statusData) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
status: '',
|
||||
failReason: null,
|
||||
publiclyAvailablePostId: [],
|
||||
},
|
||||
error: 'No status data returned',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
status: statusData.status ?? '',
|
||||
failReason: statusData.fail_reason ?? null,
|
||||
publiclyAvailablePostId: statusData.publicaly_available_post_id ?? [],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
status: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Current status of the post. Values: PROCESSING_DOWNLOAD (TikTok is downloading the video), PUBLISH_COMPLETE (successfully posted), FAILED (check failReason).',
|
||||
},
|
||||
failReason: {
|
||||
type: 'string',
|
||||
description: 'Reason for failure if status is FAILED. Null otherwise.',
|
||||
optional: true,
|
||||
},
|
||||
publiclyAvailablePostId: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Array of public post IDs once the video is published. Can be used to construct the TikTok video URL.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import type { TikTokGetUserParams, TikTokGetUserResponse } from '@/tools/tiktok/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tiktokGetUserTool: ToolConfig<TikTokGetUserParams, TikTokGetUserResponse> = {
|
||||
id: 'tiktok_get_user',
|
||||
name: 'TikTok Get User',
|
||||
description:
|
||||
'Get the authenticated TikTok user profile information including display name, avatar, bio, follower count, and video statistics.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'tiktok',
|
||||
requiredScopes: ['user.info.basic'],
|
||||
},
|
||||
|
||||
params: {
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default:
|
||||
'open_id,union_id,avatar_url,avatar_url_100,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count',
|
||||
description:
|
||||
'Comma-separated list of fields to return. Available: open_id, union_id, avatar_url, avatar_url_100, avatar_large_url, display_name, bio_description, profile_deep_link, is_verified, username, follower_count, following_count, likes_count, video_count',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params: TikTokGetUserParams) => {
|
||||
const fields =
|
||||
params.fields ||
|
||||
'open_id,union_id,avatar_url,avatar_url_100,avatar_large_url,display_name,bio_description,profile_deep_link,is_verified,username,follower_count,following_count,likes_count,video_count'
|
||||
return `https://open.tiktokapis.com/v2/user/info/?fields=${encodeURIComponent(fields)}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params: TikTokGetUserParams) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<TikTokGetUserResponse> => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error?.code !== 'ok' && data.error?.code) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
openId: '',
|
||||
unionId: null,
|
||||
displayName: '',
|
||||
avatarUrl: null,
|
||||
avatarUrl100: null,
|
||||
avatarLargeUrl: null,
|
||||
bioDescription: null,
|
||||
profileDeepLink: null,
|
||||
isVerified: null,
|
||||
username: null,
|
||||
followerCount: null,
|
||||
followingCount: null,
|
||||
likesCount: null,
|
||||
videoCount: null,
|
||||
},
|
||||
error: data.error?.message || 'Failed to fetch user info',
|
||||
}
|
||||
}
|
||||
|
||||
const user = data.data?.user
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
openId: '',
|
||||
unionId: null,
|
||||
displayName: '',
|
||||
avatarUrl: null,
|
||||
avatarUrl100: null,
|
||||
avatarLargeUrl: null,
|
||||
bioDescription: null,
|
||||
profileDeepLink: null,
|
||||
isVerified: null,
|
||||
username: null,
|
||||
followerCount: null,
|
||||
followingCount: null,
|
||||
likesCount: null,
|
||||
videoCount: null,
|
||||
},
|
||||
error: 'No user data returned',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
openId: user.open_id ?? '',
|
||||
unionId: user.union_id ?? null,
|
||||
displayName: user.display_name ?? '',
|
||||
avatarUrl: user.avatar_url ?? null,
|
||||
avatarUrl100: user.avatar_url_100 ?? null,
|
||||
avatarLargeUrl: user.avatar_large_url ?? null,
|
||||
bioDescription: user.bio_description ?? null,
|
||||
profileDeepLink: user.profile_deep_link ?? null,
|
||||
isVerified: user.is_verified ?? null,
|
||||
username: user.username ?? null,
|
||||
followerCount: user.follower_count ?? null,
|
||||
followingCount: user.following_count ?? null,
|
||||
likesCount: user.likes_count ?? null,
|
||||
videoCount: user.video_count ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
openId: {
|
||||
type: 'string',
|
||||
description: 'Unique TikTok user ID for this application',
|
||||
},
|
||||
unionId: {
|
||||
type: 'string',
|
||||
description: 'Unique TikTok user ID across all apps from the same developer',
|
||||
optional: true,
|
||||
},
|
||||
displayName: {
|
||||
type: 'string',
|
||||
description: 'User display name',
|
||||
},
|
||||
avatarUrl: {
|
||||
type: 'string',
|
||||
description: 'Profile image URL',
|
||||
optional: true,
|
||||
},
|
||||
avatarUrl100: {
|
||||
type: 'string',
|
||||
description: 'Profile image URL (100x100)',
|
||||
optional: true,
|
||||
},
|
||||
avatarLargeUrl: {
|
||||
type: 'string',
|
||||
description: 'Profile image URL (large)',
|
||||
optional: true,
|
||||
},
|
||||
bioDescription: {
|
||||
type: 'string',
|
||||
description: 'User bio description',
|
||||
optional: true,
|
||||
},
|
||||
profileDeepLink: {
|
||||
type: 'string',
|
||||
description: 'Deep link to user TikTok profile',
|
||||
optional: true,
|
||||
},
|
||||
isVerified: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the account is verified',
|
||||
optional: true,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
description: 'TikTok username',
|
||||
optional: true,
|
||||
},
|
||||
followerCount: {
|
||||
type: 'number',
|
||||
description: 'Number of followers',
|
||||
optional: true,
|
||||
},
|
||||
followingCount: {
|
||||
type: 'number',
|
||||
description: 'Number of accounts the user follows',
|
||||
optional: true,
|
||||
},
|
||||
likesCount: {
|
||||
type: 'number',
|
||||
description: 'Total likes received across all videos',
|
||||
optional: true,
|
||||
},
|
||||
videoCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of public videos',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { tiktokDirectPostVideoTool } from '@/tools/tiktok/direct_post_video'
|
||||
import { tiktokGetPostStatusTool } from '@/tools/tiktok/get_post_status'
|
||||
import { tiktokGetUserTool } from '@/tools/tiktok/get_user'
|
||||
import { tiktokListVideosTool } from '@/tools/tiktok/list_videos'
|
||||
import { tiktokQueryCreatorInfoTool } from '@/tools/tiktok/query_creator_info'
|
||||
import { tiktokQueryVideosTool } from '@/tools/tiktok/query_videos'
|
||||
|
||||
export { tiktokGetUserTool }
|
||||
export { tiktokListVideosTool }
|
||||
export { tiktokQueryVideosTool }
|
||||
export { tiktokQueryCreatorInfoTool }
|
||||
export { tiktokDirectPostVideoTool }
|
||||
export { tiktokGetPostStatusTool }
|
||||
@@ -1,133 +0,0 @@
|
||||
import type {
|
||||
TikTokListVideosParams,
|
||||
TikTokListVideosResponse,
|
||||
TikTokVideo,
|
||||
} from '@/tools/tiktok/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tiktokListVideosTool: ToolConfig<TikTokListVideosParams, TikTokListVideosResponse> = {
|
||||
id: 'tiktok_list_videos',
|
||||
name: 'TikTok List Videos',
|
||||
description:
|
||||
"Get a list of the authenticated user's TikTok videos with cover images, titles, and metadata. Supports pagination.",
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'tiktok',
|
||||
requiredScopes: ['video.list'],
|
||||
},
|
||||
|
||||
params: {
|
||||
maxCount: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
default: 20,
|
||||
description: 'Maximum number of videos to return (1-20)',
|
||||
},
|
||||
cursor: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Cursor for pagination (from previous response)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () =>
|
||||
'https://open.tiktokapis.com/v2/video/list/?fields=id,title,cover_image_url,embed_link,duration,create_time,share_url,video_description,width,height',
|
||||
method: 'POST',
|
||||
headers: (params: TikTokListVideosParams) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: TikTokListVideosParams) => ({
|
||||
max_count: params.maxCount || 20,
|
||||
...(params.cursor !== undefined && { cursor: params.cursor }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<TikTokListVideosResponse> => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error?.code !== 'ok' && data.error?.code) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
videos: [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
error: data.error?.message || 'Failed to fetch videos',
|
||||
}
|
||||
}
|
||||
|
||||
const videos: TikTokVideo[] = (data.data?.videos ?? []).map(
|
||||
(video: Record<string, unknown>) => ({
|
||||
id: video.id ?? '',
|
||||
title: video.title ?? null,
|
||||
coverImageUrl: video.cover_image_url ?? null,
|
||||
embedLink: video.embed_link ?? null,
|
||||
duration: video.duration ?? null,
|
||||
createTime: video.create_time ?? null,
|
||||
shareUrl: video.share_url ?? null,
|
||||
videoDescription: video.video_description ?? null,
|
||||
width: video.width ?? null,
|
||||
height: video.height ?? null,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
videos,
|
||||
cursor: data.data?.cursor ?? null,
|
||||
hasMore: data.data?.has_more ?? false,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
videos: {
|
||||
type: 'array',
|
||||
description: 'List of TikTok videos',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Video ID' },
|
||||
title: { type: 'string', description: 'Video title', optional: true },
|
||||
coverImageUrl: {
|
||||
type: 'string',
|
||||
description: 'Cover image URL (may expire)',
|
||||
optional: true,
|
||||
},
|
||||
embedLink: { type: 'string', description: 'Embeddable video URL', optional: true },
|
||||
duration: { type: 'number', description: 'Video duration in seconds', optional: true },
|
||||
createTime: {
|
||||
type: 'number',
|
||||
description: 'Unix timestamp when video was created',
|
||||
optional: true,
|
||||
},
|
||||
shareUrl: { type: 'string', description: 'Shareable video URL', optional: true },
|
||||
videoDescription: {
|
||||
type: 'string',
|
||||
description: 'Video description/caption',
|
||||
optional: true,
|
||||
},
|
||||
width: { type: 'number', description: 'Video width in pixels', optional: true },
|
||||
height: { type: 'number', description: 'Video height in pixels', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
cursor: {
|
||||
type: 'number',
|
||||
description: 'Cursor for fetching the next page of results',
|
||||
optional: true,
|
||||
},
|
||||
hasMore: {
|
||||
type: 'boolean',
|
||||
description: 'Whether there are more videos to fetch',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import type {
|
||||
TikTokQueryCreatorInfoParams,
|
||||
TikTokQueryCreatorInfoResponse,
|
||||
} from '@/tools/tiktok/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tiktokQueryCreatorInfoTool: ToolConfig<
|
||||
TikTokQueryCreatorInfoParams,
|
||||
TikTokQueryCreatorInfoResponse
|
||||
> = {
|
||||
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.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'tiktok',
|
||||
requiredScopes: ['video.publish'],
|
||||
},
|
||||
|
||||
params: {},
|
||||
|
||||
request: {
|
||||
url: () => 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/',
|
||||
method: 'POST',
|
||||
headers: (params: TikTokQueryCreatorInfoParams) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<TikTokQueryCreatorInfoResponse> => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error?.code !== 'ok' && data.error?.code) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
creatorAvatarUrl: null,
|
||||
creatorUsername: null,
|
||||
creatorNickname: null,
|
||||
privacyLevelOptions: [],
|
||||
commentDisabled: false,
|
||||
duetDisabled: false,
|
||||
stitchDisabled: false,
|
||||
maxVideoPostDurationSec: null,
|
||||
},
|
||||
error: data.error?.message || 'Failed to query creator info',
|
||||
}
|
||||
}
|
||||
|
||||
const creatorInfo = data.data
|
||||
|
||||
if (!creatorInfo) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
creatorAvatarUrl: null,
|
||||
creatorUsername: null,
|
||||
creatorNickname: null,
|
||||
privacyLevelOptions: [],
|
||||
commentDisabled: false,
|
||||
duetDisabled: false,
|
||||
stitchDisabled: false,
|
||||
maxVideoPostDurationSec: null,
|
||||
},
|
||||
error: 'No creator info returned',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
creatorAvatarUrl: creatorInfo.creator_avatar_url ?? null,
|
||||
creatorUsername: creatorInfo.creator_username ?? null,
|
||||
creatorNickname: creatorInfo.creator_nickname ?? null,
|
||||
privacyLevelOptions: creatorInfo.privacy_level_options ?? [],
|
||||
commentDisabled: creatorInfo.comment_disabled ?? false,
|
||||
duetDisabled: creatorInfo.duet_disabled ?? false,
|
||||
stitchDisabled: creatorInfo.stitch_disabled ?? false,
|
||||
maxVideoPostDurationSec: creatorInfo.max_video_post_duration_sec ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
creatorAvatarUrl: {
|
||||
type: 'string',
|
||||
description: 'URL of the creator avatar',
|
||||
optional: true,
|
||||
},
|
||||
creatorUsername: {
|
||||
type: 'string',
|
||||
description: 'TikTok username of the creator',
|
||||
optional: true,
|
||||
},
|
||||
creatorNickname: {
|
||||
type: 'string',
|
||||
description: 'Display name/nickname of the creator',
|
||||
optional: true,
|
||||
},
|
||||
privacyLevelOptions: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Available privacy levels for posting (e.g., PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)',
|
||||
},
|
||||
commentDisabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the creator has disabled comments by default',
|
||||
},
|
||||
duetDisabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the creator has disabled duets by default',
|
||||
},
|
||||
stitchDisabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the creator has disabled stitches by default',
|
||||
},
|
||||
maxVideoPostDurationSec: {
|
||||
type: 'number',
|
||||
description: 'Maximum allowed video duration in seconds',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import type {
|
||||
TikTokQueryVideosParams,
|
||||
TikTokQueryVideosResponse,
|
||||
TikTokVideo,
|
||||
} from '@/tools/tiktok/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const tiktokQueryVideosTool: ToolConfig<TikTokQueryVideosParams, TikTokQueryVideosResponse> =
|
||||
{
|
||||
id: 'tiktok_query_videos',
|
||||
name: 'TikTok Query Videos',
|
||||
description:
|
||||
'Query specific TikTok videos by their IDs to get fresh metadata including cover images, embed links, and video details.',
|
||||
version: '1.0.0',
|
||||
|
||||
oauth: {
|
||||
required: true,
|
||||
provider: 'tiktok',
|
||||
requiredScopes: ['video.list'],
|
||||
},
|
||||
|
||||
params: {
|
||||
videoIds: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Array of video IDs to query (maximum 20)',
|
||||
items: {
|
||||
type: 'string',
|
||||
description: 'TikTok video ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () =>
|
||||
'https://open.tiktokapis.com/v2/video/query/?fields=id,title,cover_image_url,embed_link,duration,create_time,share_url,video_description,width,height',
|
||||
method: 'POST',
|
||||
headers: (params: TikTokQueryVideosParams) => ({
|
||||
Authorization: `Bearer ${params.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params: TikTokQueryVideosParams) => ({
|
||||
filters: {
|
||||
video_ids: params.videoIds,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response): Promise<TikTokQueryVideosResponse> => {
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error?.code !== 'ok' && data.error?.code) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
videos: [],
|
||||
},
|
||||
error: data.error?.message || 'Failed to query videos',
|
||||
}
|
||||
}
|
||||
|
||||
const videos: TikTokVideo[] = (data.data?.videos ?? []).map(
|
||||
(video: Record<string, unknown>) => ({
|
||||
id: video.id ?? '',
|
||||
title: video.title ?? null,
|
||||
coverImageUrl: video.cover_image_url ?? null,
|
||||
embedLink: video.embed_link ?? null,
|
||||
duration: video.duration ?? null,
|
||||
createTime: video.create_time ?? null,
|
||||
shareUrl: video.share_url ?? null,
|
||||
videoDescription: video.video_description ?? null,
|
||||
width: video.width ?? null,
|
||||
height: video.height ?? null,
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
videos,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
videos: {
|
||||
type: 'array',
|
||||
description: 'List of queried TikTok videos',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Video ID' },
|
||||
title: { type: 'string', description: 'Video title', optional: true },
|
||||
coverImageUrl: {
|
||||
type: 'string',
|
||||
description: 'Cover image URL (fresh URL)',
|
||||
optional: true,
|
||||
},
|
||||
embedLink: { type: 'string', description: 'Embeddable video URL', optional: true },
|
||||
duration: { type: 'number', description: 'Video duration in seconds', optional: true },
|
||||
createTime: {
|
||||
type: 'number',
|
||||
description: 'Unix timestamp when video was created',
|
||||
optional: true,
|
||||
},
|
||||
shareUrl: { type: 'string', description: 'Shareable video URL', optional: true },
|
||||
videoDescription: {
|
||||
type: 'string',
|
||||
description: 'Video description/caption',
|
||||
optional: true,
|
||||
},
|
||||
width: { type: 'number', description: 'Video width in pixels', optional: true },
|
||||
height: { type: 'number', description: 'Video height in pixels', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Base params that include OAuth access token
|
||||
*/
|
||||
export interface TikTokBaseParams {
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get User Info
|
||||
*/
|
||||
export interface TikTokGetUserParams extends TikTokBaseParams {
|
||||
fields?: string
|
||||
}
|
||||
|
||||
export interface TikTokGetUserResponse extends ToolResponse {
|
||||
output: {
|
||||
openId: string
|
||||
unionId: string | null
|
||||
displayName: string
|
||||
avatarUrl: string | null
|
||||
avatarUrl100: string | null
|
||||
avatarLargeUrl: string | null
|
||||
bioDescription: string | null
|
||||
profileDeepLink: string | null
|
||||
isVerified: boolean | null
|
||||
username: string | null
|
||||
followerCount: number | null
|
||||
followingCount: number | null
|
||||
likesCount: number | null
|
||||
videoCount: number | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List Videos
|
||||
*/
|
||||
export interface TikTokListVideosParams extends TikTokBaseParams {
|
||||
maxCount?: number
|
||||
cursor?: number
|
||||
}
|
||||
|
||||
export interface TikTokVideo {
|
||||
id: string
|
||||
title: string | null
|
||||
coverImageUrl: string | null
|
||||
embedLink: string | null
|
||||
duration: number | null
|
||||
createTime: number | null
|
||||
shareUrl: string | null
|
||||
videoDescription: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
}
|
||||
|
||||
export interface TikTokListVideosResponse extends ToolResponse {
|
||||
output: {
|
||||
videos: TikTokVideo[]
|
||||
cursor: number | null
|
||||
hasMore: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Videos
|
||||
*/
|
||||
export interface TikTokQueryVideosParams extends TikTokBaseParams {
|
||||
videoIds: string[]
|
||||
}
|
||||
|
||||
export interface TikTokQueryVideosResponse extends ToolResponse {
|
||||
output: {
|
||||
videos: TikTokVideo[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Creator Info - Check posting permissions and get privacy options
|
||||
*/
|
||||
export interface TikTokQueryCreatorInfoParams extends TikTokBaseParams {}
|
||||
|
||||
export interface TikTokQueryCreatorInfoResponse extends ToolResponse {
|
||||
output: {
|
||||
creatorAvatarUrl: string | null
|
||||
creatorUsername: string | null
|
||||
creatorNickname: string | null
|
||||
privacyLevelOptions: string[]
|
||||
commentDisabled: boolean
|
||||
duetDisabled: boolean
|
||||
stitchDisabled: boolean
|
||||
maxVideoPostDurationSec: number | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct Post Video - Publish video from URL to TikTok
|
||||
*/
|
||||
export interface TikTokDirectPostVideoParams extends TikTokBaseParams {
|
||||
videoUrl: string
|
||||
title?: string
|
||||
privacyLevel: string
|
||||
disableDuet?: boolean
|
||||
disableStitch?: boolean
|
||||
disableComment?: boolean
|
||||
videoCoverTimestampMs?: number
|
||||
isAigc?: boolean
|
||||
}
|
||||
|
||||
export interface TikTokDirectPostVideoResponse extends ToolResponse {
|
||||
output: {
|
||||
publishId: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Post Status - Check status of a published post
|
||||
*/
|
||||
export interface TikTokGetPostStatusParams extends TikTokBaseParams {
|
||||
publishId: string
|
||||
}
|
||||
|
||||
export interface TikTokGetPostStatusResponse extends ToolResponse {
|
||||
output: {
|
||||
status: string
|
||||
failReason: string | null
|
||||
publiclyAvailablePostId: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all TikTok responses
|
||||
*/
|
||||
export type TikTokResponse =
|
||||
| TikTokGetUserResponse
|
||||
| TikTokListVideosResponse
|
||||
| TikTokQueryVideosResponse
|
||||
| TikTokQueryCreatorInfoResponse
|
||||
| TikTokDirectPostVideoResponse
|
||||
| TikTokGetPostStatusResponse
|
||||
Reference in New Issue
Block a user