fix(autoconnect): fixed autoconnect error for conditional block (#215)

* fix(autoconnect): fix bug where autoconnect didn't work for conditional block

* improvement(ui): file upload

* fix twilio block error

* run format

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
This commit is contained in:
Waleed Latif
2025-04-01 17:29:36 -07:00
committed by GitHub
co-authored by Emir Karabeg
parent da7c7e3687
commit 42936c89d0
35 changed files with 730 additions and 626 deletions
+9 -4
View File
@@ -320,7 +320,7 @@ export async function GET(req: NextRequest) {
)
logger.info(`[${requestId}] Executing workflow ${schedule.workflowId}`)
// Get workflow variables
let workflowVariables = {}
if (workflowRecord.variables) {
@@ -332,15 +332,20 @@ export async function GET(req: NextRequest) {
// Otherwise use as is (already parsed JSON)
workflowVariables = workflowRecord.variables
}
logger.debug(`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${schedule.workflowId}`)
logger.debug(
`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${schedule.workflowId}`
)
} catch (error) {
logger.error(`[${requestId}] Failed to parse workflow variables: ${schedule.workflowId}`, error)
logger.error(
`[${requestId}] Failed to parse workflow variables: ${schedule.workflowId}`,
error
)
// Continue execution even if variables can't be parsed
}
} else {
logger.debug(`[${requestId}] No workflow variables found for: ${schedule.workflowId}`)
}
const executor = new Executor(
serializedWorkflow,
processedBlockStates, // Use the processed block states
+7 -2
View File
@@ -631,9 +631,14 @@ async function processWebhook(
// Otherwise use as is (already parsed JSON)
workflowVariables = foundWorkflow.variables
}
logger.debug(`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${foundWorkflow.id}`)
logger.debug(
`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${foundWorkflow.id}`
)
} catch (error) {
logger.error(`[${requestId}] Failed to parse workflow variables: ${foundWorkflow.id}`, error)
logger.error(
`[${requestId}] Failed to parse workflow variables: ${foundWorkflow.id}`,
error
)
// Continue execution even if variables can't be parsed
}
} else {
@@ -260,15 +260,16 @@ describe('Workflow Execution API Route', () => {
// Verify execute was called with the input body
expect(executeMock).toHaveBeenCalledWith('workflow-id')
// Updated expectations to match actual implementation
// The structure should match: serializedWorkflow, processedBlockStates, decryptedEnvVars, processedInput, workflowVariables
expect(Executor).toHaveBeenCalledWith(
expect.anything(), // serializedWorkflow
expect.anything(), // processedBlockStates
expect.anything(), // decryptedEnvVars
expect.objectContaining({ // processedInput
input: requestBody
expect.objectContaining({
// processedInput
input: requestBody,
}),
expect.anything() // workflowVariables
)
@@ -313,8 +314,9 @@ describe('Workflow Execution API Route', () => {
expect.anything(), // serializedWorkflow
expect.anything(), // processedBlockStates
expect.anything(), // decryptedEnvVars
expect.objectContaining({ // processedInput
input: structuredInput
expect.objectContaining({
// processedInput
input: structuredInput,
}),
expect.anything() // workflowVariables
)
@@ -463,8 +465,8 @@ describe('Workflow Execution API Route', () => {
it('should pass workflow variables to the Executor', async () => {
// Create mock variables for the workflow
const workflowVariables = {
'variable1': { id: 'var1', name: 'variable1', type: 'string', value: '"test value"' },
'variable2': { id: 'var2', name: 'variable2', type: 'boolean', value: 'true' }
variable1: { id: 'var1', name: 'variable1', type: 'string', value: '"test value"' },
variable2: { id: 'var2', name: 'variable2', type: 'boolean', value: 'true' },
}
// Mock workflow with variables
@@ -539,15 +541,15 @@ describe('Workflow Execution API Route', () => {
// Verify the Executor was constructed with workflow variables
expect(executorConstructorMock).toHaveBeenCalled()
// Check that the 5th parameter (workflow variables) was passed
const executorCalls = executorConstructorMock.mock.calls
expect(executorCalls.length).toBeGreaterThan(0)
// Each call to the constructor should have at least 5 parameters
const lastCall = executorCalls[executorCalls.length - 1]
expect(lastCall.length).toBeGreaterThanOrEqual(5)
// The 5th parameter should be the workflow variables
expect(lastCall[4]).toEqual(workflowVariables)
})
+23 -16
View File
@@ -38,17 +38,22 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
}
// Log input to help debug
logger.info(`[${requestId}] Executing workflow with input:`,
input ? JSON.stringify(input, null, 2) : 'No input provided');
logger.info(
`[${requestId}] Executing workflow with input:`,
input ? JSON.stringify(input, null, 2) : 'No input provided'
)
// Validate and structure input for maximum compatibility
let processedInput = input;
let processedInput = input
if (input && typeof input === 'object') {
// Ensure input is properly structured for the starter block
if (input.input === undefined) {
// If input is not already nested, structure it properly
processedInput = { input: input };
logger.info(`[${requestId}] Restructured input for workflow:`, JSON.stringify(processedInput, null, 2));
processedInput = { input: input }
logger.info(
`[${requestId}] Restructured input for workflow:`,
JSON.stringify(processedInput, null, 2)
)
}
}
@@ -174,7 +179,9 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
// Otherwise use as is (already parsed JSON)
workflowVariables = workflow.variables
}
logger.debug(`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${workflowId}`)
logger.debug(
`[${requestId}] Loaded ${Object.keys(workflowVariables).length} workflow variables for: ${workflowId}`
)
} catch (error) {
logger.error(`[${requestId}] Failed to parse workflow variables: ${workflowId}`, error)
// Continue execution even if variables can't be parsed
@@ -188,13 +195,13 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any) {
const serializedWorkflow = new Serializer().serializeWorkflow(mergedStates, edges, loops)
const executor = new Executor(
serializedWorkflow,
processedBlockStates,
decryptedEnvVars,
processedInput,
serializedWorkflow,
processedBlockStates,
decryptedEnvVars,
processedInput,
workflowVariables
)
const result = await executor.execute(workflowId)
logger.info(`[${requestId}] Workflow execution completed: ${workflowId}`, {
@@ -278,7 +285,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const bodyText = await request.text()
logger.info(`[${requestId}] Raw request body:`, bodyText)
let body = {}
if (bodyText && bodyText.trim()) {
try {
@@ -293,9 +300,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
// Don't double-nest the input if it's already structured
const hasContent = Object.keys(body).length > 0;
const input = hasContent ? { input: body } : {};
const hasContent = Object.keys(body).length > 0
const input = hasContent ? { input: body } : {}
logger.info(`[${requestId}] Input passed to workflow:`, JSON.stringify(input, null, 2))
// Execute workflow with the structured input
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useRef } from 'react'
import { isEqual } from 'lodash'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { isEqual } from 'lodash'
/**
* Custom hook to get and set values for a sub-block in a workflow.
* Handles complex object values properly by using deep equality comparison.
*
*
* @param blockId The ID of the block containing the sub-block
* @param subBlockId The ID of the sub-block
* @param triggerWorkflowUpdate Whether to trigger a workflow update when the value changes
@@ -30,10 +30,7 @@ export function useSubBlockValue<T = any>(
// Get value from subblock store
const storeValue = useSubBlockStore(
useCallback(
(state) => state.getValue(blockId, subBlockId),
[blockId, subBlockId]
)
useCallback((state) => state.getValue(blockId, subBlockId), [blockId, subBlockId])
)
// Update the ref if the store value changes
@@ -51,16 +48,19 @@ export function useSubBlockValue<T = any>(
// Use deep comparison to avoid unnecessary updates for complex objects
if (!isEqual(valueRef.current, newValue)) {
valueRef.current = newValue
// Ensure we're passing the actual value, not a reference that might change
const valueCopy = newValue === null
? null
: (typeof newValue === 'object' ? JSON.parse(JSON.stringify(newValue)) : newValue)
const valueCopy =
newValue === null
? null
: typeof newValue === 'object'
? JSON.parse(JSON.stringify(newValue))
: newValue
// Update the subblock store with the new value
// The store's setValue method will now trigger the debounced sync automatically
useSubBlockStore.getState().setValue(blockId, subBlockId, valueCopy)
if (triggerWorkflowUpdate) {
useWorkflowStore.getState().triggerUpdate()
}
+105 -75
View File
@@ -80,6 +80,50 @@ function WorkflowContent() {
}
}, [])
// Handle drops
const findClosestOutput = useCallback(
(newNodePosition: { x: number; y: number }) => {
const existingBlocks = Object.entries(blocks)
.filter(([_, block]) => block.enabled)
.map(([id, block]) => ({
id,
type: block.type,
position: block.position,
distance: Math.sqrt(
Math.pow(block.position.x - newNodePosition.x, 2) +
Math.pow(block.position.y - newNodePosition.y, 2)
),
}))
.sort((a, b) => a.distance - b.distance)
return existingBlocks[0] ? existingBlocks[0] : null
},
[blocks]
)
// Determine the appropriate source handle based on block type
const determineSourceHandle = useCallback((block: { id: string; type: string }) => {
// Default source handle
let sourceHandle = 'source'
// For condition blocks, use the first condition handle
if (block.type === 'condition') {
// Get just the first condition handle from the DOM
const conditionHandles = document.querySelectorAll(
`[data-nodeid^="${block.id}"][data-handleid^="condition-"]`
)
if (conditionHandles.length > 0) {
// Extract the full handle ID from the first condition handle
const handleId = conditionHandles[0].getAttribute('data-handleid')
if (handleId) {
sourceHandle = handleId
}
}
}
return sourceHandle
}, [])
// Listen for toolbar block click events
useEffect(() => {
const handleAddBlockFromToolbar = (event: CustomEvent) => {
@@ -112,13 +156,16 @@ function WorkflowContent() {
// Auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (isAutoConnectEnabled && type !== 'starter') {
const closestBlockId = findClosestOutput(centerPosition)
if (closestBlockId) {
const closestBlock = findClosestOutput(centerPosition)
if (closestBlock) {
// Get appropriate source handle
const sourceHandle = determineSourceHandle(closestBlock)
addEdge({
id: crypto.randomUUID(),
source: closestBlockId,
source: closestBlock.id,
target: id,
sourceHandle: 'source',
sourceHandle,
targetHandle: 'target',
type: 'custom',
})
@@ -134,7 +181,60 @@ function WorkflowContent() {
handleAddBlockFromToolbar as EventListener
)
}
}, [project, blocks, addBlock, addEdge])
}, [project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle])
// Update the onDrop handler
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault()
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'))
if (data.type === 'connectionBlock') return
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
const position = project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
})
const blockConfig = getBlock(data.type)
if (!blockConfig) {
logger.error('Invalid block type:', { data })
return
}
const id = crypto.randomUUID()
const name = `${blockConfig.name} ${
Object.values(blocks).filter((b) => b.type === data.type).length + 1
}`
addBlock(id, data.type, name, position)
// Auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (isAutoConnectEnabled && data.type !== 'starter') {
const closestBlock = findClosestOutput(position)
if (closestBlock) {
// Get appropriate source handle
const sourceHandle = determineSourceHandle(closestBlock)
addEdge({
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
} catch (err) {
logger.error('Error dropping block:', { err })
}
},
[project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle]
)
// Init workflow
useEffect(() => {
@@ -305,76 +405,6 @@ function WorkflowContent() {
[addEdge]
)
// Handle drops
const findClosestOutput = useCallback(
(newNodePosition: { x: number; y: number }) => {
const existingBlocks = Object.entries(blocks)
.filter(([_, block]) => block.enabled && block.type !== 'condition')
.map(([id, block]) => ({
id,
position: block.position,
distance: Math.sqrt(
Math.pow(block.position.x - newNodePosition.x, 2) +
Math.pow(block.position.y - newNodePosition.y, 2)
),
}))
.sort((a, b) => a.distance - b.distance)
return existingBlocks[0]?.id
},
[blocks]
)
// Update the onDrop handler
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault()
try {
const data = JSON.parse(event.dataTransfer.getData('application/json'))
if (data.type === 'connectionBlock') return
const reactFlowBounds = event.currentTarget.getBoundingClientRect()
const position = project({
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
})
const blockConfig = getBlock(data.type)
if (!blockConfig) {
logger.error('Invalid block type:', { data })
return
}
const id = crypto.randomUUID()
const name = `${blockConfig.name} ${
Object.values(blocks).filter((b) => b.type === data.type).length + 1
}`
addBlock(id, data.type, name, position)
// Auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
if (isAutoConnectEnabled && data.type !== 'starter') {
const closestBlockId = findClosestOutput(position)
if (closestBlockId) {
addEdge({
id: crypto.randomUUID(),
source: closestBlockId,
target: id,
sourceHandle: 'source',
targetHandle: 'target',
type: 'custom',
})
}
}
} catch (err) {
logger.error('Error dropping block:', { err })
}
},
[project, blocks, addBlock, addEdge, findClosestOutput]
)
// Update onPaneClick to only handle edge selection
const onPaneClick = useCallback(() => {
setSelectedEdgeId(null)
+9 -10
View File
@@ -1,13 +1,12 @@
import { TwilioIcon } from '@/components/icons'
import { BlockCategory, BlockConfig, BlockIcon } from '../types'
import { TwilioSMSBlockOutput } from '@/tools/twilio/types'
import { BlockCategory, BlockConfig, BlockIcon } from '../types'
export const TwilioSMSBlock: BlockConfig<TwilioSMSBlockOutput> = {
type: 'twilio_sms',
name: 'Twilio SMS',
description: 'Send SMS messages via Twilio',
longDescription:
'Send text messages to single or multiple recipients using the Twilio API.',
longDescription: 'Send text messages to single or multiple recipients using the Twilio API.',
category: 'tools',
bgColor: '#F22F46', // Twilio brand color
icon: TwilioIcon,
@@ -47,12 +46,12 @@ export const TwilioSMSBlock: BlockConfig<TwilioSMSBlockOutput> = {
type: 'short-input',
layout: 'full',
placeholder: 'e.g. +1234567890',
}
},
],
tools: {
access: ['twilio_send_sms'],
config: {
tool: () => 'twilio_send_sms'
tool: () => 'twilio_send_sms',
},
},
inputs: {
@@ -60,16 +59,16 @@ export const TwilioSMSBlock: BlockConfig<TwilioSMSBlockOutput> = {
message: { type: 'string', required: true },
accountSid: { type: 'string', required: true },
authToken: { type: 'string', required: true },
fromNumber: { type: 'string', required: true }
fromNumber: { type: 'string', required: true },
},
outputs: {
response: {
type: {
success: 'boolean',
messageId: 'string',
status: 'string',
error: 'string'
messageId: 'any',
status: 'any',
error: 'any',
},
},
},
}
}
+64 -61
View File
@@ -3,72 +3,75 @@ import { ToolResponse } from '@/tools/types'
import { BlockConfig } from '../types'
interface TypeformResponse extends ToolResponse {
output: {
total_items: number
page_count: number
items: Array<{
landing_id: string
token: string
landed_at: string
submitted_at: string
metadata: {
user_agent: string
platform: string
referer: string
network_id: string
browser: string
output:
| {
total_items: number
page_count: number
items: Array<{
landing_id: string
token: string
landed_at: string
submitted_at: string
metadata: {
user_agent: string
platform: string
referer: string
network_id: string
browser: string
}
answers: Array<{
field: {
id: string
type: string
ref: string
}
type: string
[key: string]: any // For different answer types (text, boolean, number, etc.)
}>
hidden: Record<string, any>
calculated: {
score: number
}
variables: Array<{
key: string
type: string
[key: string]: any // For different variable types
}>
}>
}
answers: Array<{
field: {
| {
fileUrl: string
contentType: string
filename: string
}
| {
fields: Array<{
dropoffs: number
id: string
type: string
label: string
ref: string
title: string
type: string
views: number
}>
form: {
platforms: Array<{
average_time: number
completion_rate: number
platform: string
responses_count: number
total_visits: number
unique_visits: number
}>
summary: {
average_time: number
completion_rate: number
responses_count: number
total_visits: number
unique_visits: number
}
}
type: string
[key: string]: any // For different answer types (text, boolean, number, etc.)
}>
hidden: Record<string, any>
calculated: {
score: number
}
variables: Array<{
key: string
type: string
[key: string]: any // For different variable types
}>
}>
} | {
fileUrl: string
contentType: string
filename: string
} | {
fields: Array<{
dropoffs: number
id: string
label: string
ref: string
title: string
type: string
views: number
}>
form: {
platforms: Array<{
average_time: number
completion_rate: number
platform: string
responses_count: number
total_visits: number
unique_visits: number
}>
summary: {
average_time: number
completion_rate: number
responses_count: number
total_visits: number
unique_visits: number
}
}
}
}
export const TypeformBlock: BlockConfig<TypeformResponse> = {
+2 -2
View File
@@ -1,5 +1,6 @@
// Import blocks
import { AgentBlock } from './blocks/agent'
import { AirtableBlock } from './blocks/airtable'
import { ApiBlock } from './blocks/api'
import { ConditionBlock } from './blocks/condition'
import { ConfluenceBlock } from './blocks/confluence'
@@ -28,14 +29,13 @@ import { StarterBlock } from './blocks/starter'
import { SupabaseBlock } from './blocks/supabase'
import { TavilyBlock } from './blocks/tavily'
import { TranslateBlock } from './blocks/translate'
import { TwilioSMSBlock } from './blocks/twilio'
import { TypeformBlock } from './blocks/typeform'
import { VisionBlock } from './blocks/vision'
import { WhatsAppBlock } from './blocks/whatsapp'
import { XBlock } from './blocks/x'
import { YouTubeBlock } from './blocks/youtube'
import { AirtableBlock } from './blocks/airtable'
import { BlockConfig } from './types'
import { TwilioSMSBlock } from './blocks/twilio'
// Export blocks for ease of use
export {
+5 -5
View File
@@ -1745,11 +1745,11 @@ export function TwilioIcon(props: SVGProps<SVGSVGElement>) {
fill="none"
aria-hidden="true"
>
<circle cx="128" cy="128" r="128" fill="none" stroke="white" strokeWidth="21"/>
<circle cx="85" cy="85" r="21" fill="white"/>
<circle cx="171" cy="85" r="21" fill="white"/>
<circle cx="85" cy="171" r="21" fill="white"/>
<circle cx="171" cy="171" r="21" fill="white"/>
<circle cx="128" cy="128" r="128" fill="none" stroke="white" strokeWidth="21" />
<circle cx="85" cy="85" r="21" fill="white" />
<circle cx="171" cy="85" r="21" fill="white" />
<circle cx="85" cy="171" r="21" fill="white" />
<circle cx="171" cy="171" r="21" fill="white" />
</svg>
)
}
+6
View File
@@ -54,6 +54,12 @@ import { cn } from '@/lib/utils'
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive> & {
+16 -10
View File
@@ -819,32 +819,38 @@ export class ApiBlockHandler implements BlockHandler {
}
try {
let processedInputs = { ...inputs };
let processedInputs = { ...inputs }
// Handle body specifically to ensure it's properly processed for API requests
if (processedInputs.body !== undefined) {
// If body is a string that looks like JSON, parse it
if (typeof processedInputs.body === 'string') {
try {
// Trim whitespace before checking for JSON pattern
const trimmedBody = processedInputs.body.trim();
const trimmedBody = processedInputs.body.trim()
if (trimmedBody.startsWith('{') || trimmedBody.startsWith('[')) {
processedInputs.body = JSON.parse(trimmedBody);
logger.info('[ApiBlockHandler] Parsed JSON body:', JSON.stringify(processedInputs.body, null, 2));
processedInputs.body = JSON.parse(trimmedBody)
logger.info(
'[ApiBlockHandler] Parsed JSON body:',
JSON.stringify(processedInputs.body, null, 2)
)
}
} catch (e) {
logger.info('[ApiBlockHandler] Failed to parse body as JSON, using as string:', e);
logger.info('[ApiBlockHandler] Failed to parse body as JSON, using as string:', e)
// Keep as string if parsing fails
}
} else if (processedInputs.body === null) {
// Convert null to undefined for consistency with API expectations
processedInputs.body = undefined;
processedInputs.body = undefined
}
}
// Ensure the final processed body is logged
logger.info('[ApiBlockHandler] Final processed request body:', JSON.stringify(processedInputs.body, null, 2));
logger.info(
'[ApiBlockHandler] Final processed request body:',
JSON.stringify(processedInputs.body, null, 2)
)
const result = await executeTool(block.config.tool, {
...processedInputs,
_context: { workflowId: context.workflowId },
+74 -63
View File
@@ -45,12 +45,12 @@ export class Executor {
this.validateWorkflow()
if (workflowInput) {
this.workflowInput = workflowInput;
logger.info('[Executor] Using workflow input:', JSON.stringify(this.workflowInput, null, 2));
this.workflowInput = workflowInput
logger.info('[Executor] Using workflow input:', JSON.stringify(this.workflowInput, null, 2))
} else {
this.workflowInput = {};
this.workflowInput = {}
}
this.loopManager = new LoopManager(workflow.loops || {})
this.resolver = new InputResolver(
workflow,
@@ -421,70 +421,75 @@ export class Executor {
})
} else {
*/
// No input format defined or not an array,
// Handle API call - prioritize using the input as-is
if (this.workflowInput && typeof this.workflowInput === 'object') {
// For API calls, extract input from the nested structure if it exists
const inputData = this.workflowInput.input !== undefined
? this.workflowInput.input // Use the nested input data
: this.workflowInput; // Fallback to direct input
// Create starter output with both formats for maximum compatibility
const starterOutput = {
response: {
input: inputData,
...inputData, // Make fields directly accessible at response level
},
}
// No input format defined or not an array,
// Handle API call - prioritize using the input as-is
if (this.workflowInput && typeof this.workflowInput === 'object') {
// For API calls, extract input from the nested structure if it exists
const inputData =
this.workflowInput.input !== undefined
? this.workflowInput.input // Use the nested input data
: this.workflowInput // Fallback to direct input
logger.info(`[Executor] API input for starter block:`, {
type: typeof inputData,
isArray: Array.isArray(inputData),
keys: Object.keys(inputData),
inputData: JSON.stringify(inputData, null, 2),
});
logger.info(`[Executor] Final starter block output:`, JSON.stringify(starterOutput, null, 2));
context.blockStates.set(starterBlock.id, {
output: starterOutput,
executed: true,
executionTime: 0,
})
} else {
// Fallback for other cases
const starterOutput = {
response: {
input: this.workflowInput,
},
}
logger.info(`[Executor] Simple starter output:`, JSON.stringify(starterOutput, null, 2));
context.blockStates.set(starterBlock.id, {
output: starterOutput,
executed: true,
executionTime: 0,
})
// Create starter output with both formats for maximum compatibility
const starterOutput = {
response: {
input: inputData,
...inputData, // Make fields directly accessible at response level
},
}
logger.info(`[Executor] API input for starter block:`, {
type: typeof inputData,
isArray: Array.isArray(inputData),
keys: Object.keys(inputData),
inputData: JSON.stringify(inputData, null, 2),
})
logger.info(
`[Executor] Final starter block output:`,
JSON.stringify(starterOutput, null, 2)
)
context.blockStates.set(starterBlock.id, {
output: starterOutput,
executed: true,
executionTime: 0,
})
} else {
// Fallback for other cases
const starterOutput = {
response: {
input: this.workflowInput,
},
}
logger.info(`[Executor] Simple starter output:`, JSON.stringify(starterOutput, null, 2))
context.blockStates.set(starterBlock.id, {
output: starterOutput,
executed: true,
executionTime: 0,
})
}
//} // End of inputFormat conditional
} catch (e) {
logger.warn('Error processing starter block input format:', e)
// Fallback to raw input with both paths accessible
// Ensure we handle both input formats
const inputData = this.workflowInput.input !== undefined
? this.workflowInput.input // Use nested input if available
: this.workflowInput; // Fallback to direct input
const inputData =
this.workflowInput.input !== undefined
? this.workflowInput.input // Use nested input if available
: this.workflowInput // Fallback to direct input
const starterOutput = {
response: {
input: inputData,
...inputData, // Add input fields directly at response level too
},
}
logger.info(`[Executor] Fallback starter output:`, JSON.stringify(starterOutput, null, 2));
logger.info(`[Executor] Fallback starter output:`, JSON.stringify(starterOutput, null, 2))
context.blockStates.set(starterBlock.id, {
output: starterOutput,
@@ -493,8 +498,8 @@ export class Executor {
})
}
// Ensure the starter block is in the active execution path
context.activeExecutionPath.add(starterBlock.id);
// Mark the starter block as executed
context.activeExecutionPath.add(starterBlock.id)
// Mark the starter block as executed
context.executedBlocks.add(starterBlock.id)
// Add all blocks connected to the starter to the active execution path
@@ -688,18 +693,24 @@ export class Executor {
if (starterBlock) {
const starterState = context.blockStates.get(starterBlock.id)
if (!starterState) {
logger.warn(`Starter block state not found when executing ${block.metadata?.name || blockId}. This may cause reference errors.`)
logger.warn(
`Starter block state not found when executing ${block.metadata?.name || blockId}. This may cause reference errors.`
)
} else {
logger.debug(`Starter block state available for ${block.metadata?.name || blockId}:`,
JSON.stringify(starterState.output || {}, null, 2));
logger.debug(
`Starter block state available for ${block.metadata?.name || blockId}:`,
JSON.stringify(starterState.output || {}, null, 2)
)
}
}
// Resolve inputs (which will look up references to other blocks including starter)
const inputs = this.resolver.resolveInputs(block, context)
logger.debug(`Resolved inputs for ${block.metadata?.name || blockId}:`,
JSON.stringify(inputs, null, 2));
logger.debug(
`Resolved inputs for ${block.metadata?.name || blockId}:`,
JSON.stringify(inputs, null, 2)
)
// Find the appropriate handler
const handler = this.blockHandlers.find((h) => h.canHandle(block))
if (!handler) {
+114 -87
View File
@@ -1,7 +1,7 @@
import { createLogger } from '@/lib/logs/console-logger'
import { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import { LoopManager } from './loops'
import { ExecutionContext } from './types'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('InputResolver')
@@ -82,28 +82,28 @@ export class InputResolver {
// Special handling for different block types
const isFunctionBlock = block.metadata?.id === 'function'
const isApiBlock = block.metadata?.id === 'api'
// For function blocks, we need special handling for code input
if (isFunctionBlock && key === 'code') {
// For code input in function blocks, we don't want to parse JSON
result[key] = resolvedValue
logger.debug(`[resolveInputs] Function block code input preserved as string`);
}
logger.debug(`[resolveInputs] Function block code input preserved as string`)
}
// For API blocks, handle body input specially
else if (isApiBlock && key === 'body') {
try {
// If it's JSON-looking, preserve its structure
if (resolvedValue.trim().startsWith('{') || resolvedValue.trim().startsWith('[')) {
result[key] = JSON.parse(resolvedValue)
logger.debug(`[resolveInputs] API block body parsed as JSON object`);
logger.debug(`[resolveInputs] API block body parsed as JSON object`)
} else {
result[key] = resolvedValue
logger.debug(`[resolveInputs] API block body preserved as string`);
logger.debug(`[resolveInputs] API block body preserved as string`)
}
} catch {
// If parsing fails, keep as string
result[key] = resolvedValue
logger.debug(`[resolveInputs] API block body JSON parsing failed, keeping as string`);
logger.debug(`[resolveInputs] API block body JSON parsing failed, keeping as string`)
}
}
// For other inputs, try to convert JSON strings to objects
@@ -111,7 +111,7 @@ export class InputResolver {
try {
if (resolvedValue.startsWith('{') || resolvedValue.startsWith('[')) {
result[key] = JSON.parse(resolvedValue)
logger.debug(`[resolveInputs] Parsed JSON value for ${key}`);
logger.debug(`[resolveInputs] Parsed JSON value for ${key}`)
} else {
result[key] = resolvedValue
}
@@ -158,16 +158,18 @@ export class InputResolver {
if (foundVariable) {
const [_, variable] = foundVariable
// Process variable value based on its type
let processedValue = variable.value
// Handle string values that could be stored with quotes
if (variable.type === 'string' && typeof processedValue === 'string') {
// If the string value starts and ends with quotes, remove them
const trimmed = processedValue.trim()
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
// Remove the quotes and unescape any escaped quotes
processedValue = trimmed.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'")
}
@@ -184,30 +186,32 @@ export class InputResolver {
}
}
// Handle object/array values that might be stored as JSON strings
else if ((variable.type === 'object' || variable.type === 'array') &&
typeof processedValue === 'string') {
else if (
(variable.type === 'object' || variable.type === 'array') &&
typeof processedValue === 'string'
) {
try {
processedValue = JSON.parse(processedValue)
} catch (e) {
// Keep as string if parsing fails
}
}
// Determine if this needs to be a code-compatible string literal
const needsCodeStringLiteral = this.needsCodeStringLiteral(currentBlock, value);
const needsCodeStringLiteral = this.needsCodeStringLiteral(currentBlock, value)
// Format the processed value for insertion into the string based on context
let formattedValue: string;
let formattedValue: string
if (variable.type === 'string' && needsCodeStringLiteral) {
// For code contexts like function and condition blocks, properly quote strings
formattedValue = JSON.stringify(processedValue);
formattedValue = JSON.stringify(processedValue)
} else if (typeof processedValue === 'object' && processedValue !== null) {
// For objects, always stringify
formattedValue = JSON.stringify(processedValue);
formattedValue = JSON.stringify(processedValue)
} else {
// For other types in normal contexts, use simple string conversion
formattedValue = String(processedValue);
formattedValue = String(processedValue)
}
resolvedValue = resolvedValue.replace(match, formattedValue)
@@ -220,51 +224,51 @@ export class InputResolver {
/**
* Determines if a value needs to be formatted as a code-compatible string literal
* based on the block type and context. Handles JavaScript and other code contexts.
*
*
* @param block - The block where the value is being used
* @param expression - The expression containing the value
* @returns Whether the value should be formatted as a string literal
*/
private needsCodeStringLiteral(block?: SerializedBlock, expression?: string): boolean {
if (!block) return false;
if (!block) return false
// These block types execute code and need properly formatted string literals
const codeExecutionBlocks = ['function', 'condition'];
const codeExecutionBlocks = ['function', 'condition']
// Check if this is a block that executes code
if (block.metadata?.id && codeExecutionBlocks.includes(block.metadata.id)) {
return true;
return true
}
// Check if the expression is likely part of code
if (expression) {
const codeIndicators = [
// Function/method calls
/\(\s*$/, // Function call
/\.\w+\s*\(/, // Method call
/\(\s*$/, // Function call
/\.\w+\s*\(/, // Method call
// JavaScript/Python operators
/[=<>!+\-*/%](?:==?)?/, // Common operators
/\+=|-=|\*=|\/=|%=|\*\*=?/, // Assignment operators
/[=<>!+\-*/%](?:==?)?/, // Common operators
/\+=|-=|\*=|\/=|%=|\*\*=?/, // Assignment operators
// JavaScript keywords
/\b(if|else|for|while|return|var|let|const|function)\b/,
// Python keywords
/\b(if|else|elif|for|while|def|return|import|from|as|class|with|try|except)\b/,
// Common code patterns
/^['"]use strict['"];?$/, // JS strict mode
/\$\{.+?\}/, // JS template literals
/f['"].*?['"]/, // Python f-strings
/\bprint\s*\(/, // Python print
/\bconsole\.\w+\(/ // JS console methods
];
return codeIndicators.some(pattern => pattern.test(expression));
/^['"]use strict['"];?$/, // JS strict mode
/\$\{.+?\}/, // JS template literals
/f['"].*?['"]/, // Python f-strings
/\bprint\s*\(/, // Python print
/\bconsole\.\w+\(/, // JS console methods
]
return codeIndicators.some((pattern) => pattern.test(expression))
}
return false;
return false
}
/**
@@ -301,8 +305,8 @@ export class InputResolver {
blockRef,
pathParts,
currentBlock: currentBlock.id,
currentBlockType: currentBlock.metadata?.id
});
currentBlockType: currentBlock.metadata?.id,
})
// Special case for "start" references
// This allows users to reference the starter block using <start.response.type.input>
@@ -311,80 +315,100 @@ export class InputResolver {
// Find the starter block
const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter')
if (starterBlock) {
logger.debug(`[resolveBlockReferences] Found starter block with ID: ${starterBlock.id}`);
logger.debug(`[resolveBlockReferences] Found starter block with ID: ${starterBlock.id}`)
const blockState = context.blockStates.get(starterBlock.id)
if (blockState) {
logger.debug(`[resolveBlockReferences] Starter block state:`, JSON.stringify(blockState, null, 2));
logger.debug(
`[resolveBlockReferences] Starter block state:`,
JSON.stringify(blockState, null, 2)
)
// Navigate through the path parts
let replacementValue: any = blockState.output
// Log the initial output value from the starter block
logger.debug(`[resolveBlockReferences] Initial starter output:`, JSON.stringify(replacementValue, null, 2));
logger.debug(
`[resolveBlockReferences] Initial starter output:`,
JSON.stringify(replacementValue, null, 2)
)
for (const part of pathParts) {
logger.debug(`[resolveBlockReferences] Navigating path part: ${part}`, {
currentValue: typeof replacementValue === 'object' ?
JSON.stringify(replacementValue) : replacementValue
});
logger.debug(`[resolveBlockReferences] Navigating path part: ${part}`, {
currentValue:
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: replacementValue,
})
if (!replacementValue || typeof replacementValue !== 'object') {
logger.warn(`[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`, replacementValue);
logger.warn(
`[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`,
replacementValue
)
throw new Error(`Invalid path "${part}" in "${path}" for starter block.`)
}
replacementValue = replacementValue[part]
if (replacementValue === undefined) {
logger.warn(`[resolveBlockReferences] No value found at path "${part}" in starter block.`);
logger.warn(
`[resolveBlockReferences] No value found at path "${part}" in starter block.`
)
throw new Error(`No value found at path "${path}" in starter block.`)
}
}
// Format the value based on block type and path
let formattedValue: string;
let formattedValue: string
// Special handling for all blocks referencing starter input
if (blockRef.toLowerCase() === 'start' && pathParts.join('.').includes('input')) {
const blockType = currentBlock.metadata?.id;
const blockType = currentBlock.metadata?.id
// Format based on which block is consuming this value
if (typeof replacementValue === 'object' && replacementValue !== null) {
// For function blocks, preserve the object structure for code usage
if (blockType === 'function') {
logger.debug(`[resolveBlockReferences] Special handling for function input:`,
JSON.stringify(replacementValue, null, 2));
formattedValue = JSON.stringify(replacementValue);
logger.debug(
`[resolveBlockReferences] Special handling for function input:`,
JSON.stringify(replacementValue, null, 2)
)
formattedValue = JSON.stringify(replacementValue)
}
// For API blocks, handle body special case
else if (blockType === 'api') {
logger.debug(`[resolveBlockReferences] Special handling for API input:`,
JSON.stringify(replacementValue, null, 2));
formattedValue = JSON.stringify(replacementValue);
}
logger.debug(
`[resolveBlockReferences] Special handling for API input:`,
JSON.stringify(replacementValue, null, 2)
)
formattedValue = JSON.stringify(replacementValue)
}
// For condition blocks, ensure proper formatting
else if (blockType === 'condition') {
logger.debug(`[resolveBlockReferences] Special handling for condition input:`,
JSON.stringify(replacementValue, null, 2));
formattedValue = this.stringifyForCondition(replacementValue);
logger.debug(
`[resolveBlockReferences] Special handling for condition input:`,
JSON.stringify(replacementValue, null, 2)
)
formattedValue = this.stringifyForCondition(replacementValue)
}
// For all other blocks, stringify objects
else {
formattedValue = JSON.stringify(replacementValue);
formattedValue = JSON.stringify(replacementValue)
}
} else {
// For primitive values
formattedValue = String(replacementValue);
formattedValue = String(replacementValue)
}
} else {
// Standard handling for non-input references
formattedValue = typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: String(replacementValue);
formattedValue =
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: String(replacementValue)
}
logger.debug(`[resolveBlockReferences] Resolved value:`, formattedValue);
logger.debug(`[resolveBlockReferences] Resolved value:`, formattedValue)
resolvedValue = resolvedValue.replace(match, formattedValue)
continue
}
@@ -578,7 +602,10 @@ export class InputResolver {
if (currentBlock.metadata?.id === 'condition') {
formattedValue = this.stringifyForCondition(replacementValue)
} else if (typeof replacementValue === 'string' && this.needsCodeStringLiteral(currentBlock, value)) {
} else if (
typeof replacementValue === 'string' &&
this.needsCodeStringLiteral(currentBlock, value)
) {
// For code blocks, quote string values properly for the given language
formattedValue = JSON.stringify(replacementValue)
} else {
+9 -2
View File
@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import {
AirtableIcon,
ConfluenceIcon,
GithubIcon,
GmailIcon,
@@ -10,14 +11,20 @@ import {
GoogleSheetsIcon,
SupabaseIcon,
xIcon,
AirtableIcon,
} from '@/components/icons'
import { createLogger } from '@/lib/logs/console-logger'
const logger = createLogger('OAuth')
// Define the base OAuth provider type
export type OAuthProvider = 'google' | 'github' | 'x' | 'supabase' | 'confluence' | 'airtable' | string
export type OAuthProvider =
| 'google'
| 'github'
| 'x'
| 'supabase'
| 'confluence'
| 'airtable'
| string
export type OAuthService =
| 'google'
| 'google-email'
+4 -4
View File
@@ -59,7 +59,7 @@ export const useSubBlockStore = create<SubBlockStore>()(
// Persist to localStorage for backup
const currentValues = get().workflowValues[activeWorkflowId] || {}
saveSubblockValues(activeWorkflowId, currentValues)
// Trigger debounced sync to DB
get().syncWithDB()
},
@@ -83,7 +83,7 @@ export const useSubBlockStore = create<SubBlockStore>()(
}))
saveSubblockValues(activeWorkflowId, {})
// Trigger sync to DB immediately on clear
workflowSync.sync()
},
@@ -121,14 +121,14 @@ export const useSubBlockStore = create<SubBlockStore>()(
// Save to localStorage
saveSubblockValues(workflowId, values)
},
// Debounced sync function to trigger DB sync
syncWithDB: () => {
// Clear any existing timeout
if (syncDebounceTimer) {
clearTimeout(syncDebounceTimer)
}
// Set new timeout
syncDebounceTimer = setTimeout(() => {
// Trigger workflow sync to DB
+14 -16
View File
@@ -5,7 +5,7 @@
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { ToolTester } from '../__test-utils__/test-tools'
import { airtableReadTool, airtableWriteTool, airtableUpdateTool } from './index'
import { airtableReadTool, airtableUpdateTool, airtableWriteTool } from './index'
describe('Airtable Tools Integration', () => {
let tester: ToolTester
@@ -30,7 +30,7 @@ describe('Airtable Tools Integration', () => {
tableId: 'table456',
accessToken: 'token789',
maxRecords: 100,
filterFormula: "Status='Active'"
filterFormula: "Status='Active'",
}
const url = tester.getRequestUrl(params)
@@ -46,9 +46,9 @@ describe('Airtable Tools Integration', () => {
const mockData = {
records: [
{ id: 'rec1', fields: { Name: 'Test 1' } },
{ id: 'rec2', fields: { Name: 'Test 2' } }
{ id: 'rec2', fields: { Name: 'Test 2' } },
],
offset: 'next_page_token'
offset: 'next_page_token',
}
tester.setup(mockData)
@@ -56,7 +56,7 @@ describe('Airtable Tools Integration', () => {
const result = await tester.execute({
baseId: 'base123',
tableId: 'table456',
accessToken: 'token789'
accessToken: 'token789',
})
expect(result.success).toBe(true)
@@ -76,7 +76,7 @@ describe('Airtable Tools Integration', () => {
baseId: 'base123',
tableId: 'table456',
accessToken: 'token789',
records: [{ fields: { Name: 'New Record' } }]
records: [{ fields: { Name: 'New Record' } }],
}
const url = tester.getRequestUrl(params)
@@ -90,9 +90,7 @@ describe('Airtable Tools Integration', () => {
test('should handle successful write response', async () => {
const mockData = {
records: [
{ id: 'rec1', fields: { Name: 'New Record' } }
]
records: [{ id: 'rec1', fields: { Name: 'New Record' } }],
}
tester.setup(mockData)
@@ -101,7 +99,7 @@ describe('Airtable Tools Integration', () => {
baseId: 'base123',
tableId: 'table456',
accessToken: 'token789',
records: [{ fields: { Name: 'New Record' } }]
records: [{ fields: { Name: 'New Record' } }],
})
expect(result.success).toBe(true)
@@ -121,7 +119,7 @@ describe('Airtable Tools Integration', () => {
tableId: 'table456',
recordId: 'rec789',
accessToken: 'token789',
fields: { Name: 'Updated Record' }
fields: { Name: 'Updated Record' },
}
const url = tester.getRequestUrl(params)
@@ -136,7 +134,7 @@ describe('Airtable Tools Integration', () => {
test('should handle successful update response', async () => {
const mockData = {
id: 'rec789',
fields: { Name: 'Updated Record' }
fields: { Name: 'Updated Record' },
}
tester.setup(mockData)
@@ -146,7 +144,7 @@ describe('Airtable Tools Integration', () => {
tableId: 'table456',
recordId: 'rec789',
accessToken: 'token789',
fields: { Name: 'Updated Record' }
fields: { Name: 'Updated Record' },
})
expect(result.success).toBe(true)
@@ -158,17 +156,17 @@ describe('Airtable Tools Integration', () => {
test('should handle error responses', async () => {
tester = new ToolTester(airtableReadTool)
const errorMessage = 'Invalid API key'
tester.setup({ error: errorMessage }, { ok: false, status: 401 })
const result = await tester.execute({
baseId: 'base123',
tableId: 'table456',
accessToken: 'invalid_token'
accessToken: 'invalid_token',
})
expect(result.success).toBe(false)
expect(result.error).toContain('Failed to read Airtable records')
})
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
import { readTool } from './read'
import { writeTool } from './write'
import { updateTool } from './update'
import { writeTool } from './write'
export const airtableReadTool = readTool
export const airtableWriteTool = writeTool
export const airtableUpdateTool = updateTool
export const airtableUpdateTool = updateTool
+7 -7
View File
@@ -6,12 +6,12 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
name: 'Airtable Read Records',
description: 'Read records from an Airtable table',
version: '1.0.0',
oauth: {
required: true,
provider: 'airtable',
},
params: {
accessToken: {
type: 'string',
@@ -39,7 +39,7 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
description: 'Formula to filter records',
},
},
request: {
url: (params) => {
const url = `https://api.airtable.com/v0/${params.baseId}/${params.tableId}`
@@ -54,11 +54,11 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
},
method: 'GET',
headers: (params) => ({
'Authorization': `Bearer ${params.accessToken}`,
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response) => {
const data = await response.json()
return {
@@ -72,8 +72,8 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
},
}
},
transformError: (error) => {
return `Failed to read Airtable records: ${error.message}`
},
}
}
+1 -1
View File
@@ -66,4 +66,4 @@ export interface AirtableUpdateParams {
tableId: string
recordId: string
fields: Record<string, any>
}
}
+8 -9
View File
@@ -6,12 +6,12 @@ export const writeTool: ToolConfig<AirtableWriteParams, AirtableWriteResponse> =
name: 'Airtable Write Records',
description: 'Write new records to an Airtable table',
version: '1.0.0',
oauth: {
required: true,
provider: 'airtable',
},
params: {
accessToken: {
type: 'string',
@@ -34,18 +34,17 @@ export const writeTool: ToolConfig<AirtableWriteParams, AirtableWriteResponse> =
description: 'Array of records to create',
},
},
request: {
url: (params) =>
`https://api.airtable.com/v0/${params.baseId}/${params.tableId}`,
url: (params) => `https://api.airtable.com/v0/${params.baseId}/${params.tableId}`,
method: 'POST',
headers: (params) => ({
'Authorization': `Bearer ${params.accessToken}`,
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => ({ records: params.records }),
},
transformResponse: async (response) => {
const data = await response.json()
return {
@@ -58,8 +57,8 @@ export const writeTool: ToolConfig<AirtableWriteParams, AirtableWriteResponse> =
},
}
},
transformError: (error) => {
return `Failed to write Airtable records: ${error.message}`
},
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
import { fileParserTool } from './parser'
export const fileParseTool = fileParserTool
export const fileParseTool = fileParserTool
+2 -2
View File
@@ -1,6 +1,7 @@
import { createLogger } from '@/lib/logs/console-logger'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { airtableReadTool, airtableUpdateTool, airtableWriteTool } from './airtable'
import { confluenceListTool, confluenceRetrieveTool, confluenceUpdateTool } from './confluence'
import { docsCreateTool, docsReadTool, docsWriteTool } from './docs'
import { driveDownloadTool, driveListTool, driveUploadTool } from './drive'
@@ -37,6 +38,7 @@ import { sheetsReadTool, sheetsUpdateTool, sheetsWriteTool } from './sheets'
import { slackMessageTool } from './slack/message'
import { supabaseInsertTool, supabaseQueryTool, supabaseUpdateTool } from './supabase'
import { tavilyExtractTool, tavilySearchTool } from './tavily'
import { sendSMSTool } from './twilio/send'
import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from './typeform'
import { OAuthTokenPayload, ToolConfig, ToolResponse } from './types'
import { formatRequestParams, validateToolRequest } from './utils'
@@ -44,8 +46,6 @@ import { visionTool } from './vision/vision'
import { whatsappSendMessageTool } from './whatsapp'
import { xReadTool, xSearchTool, xUserTool, xWriteTool } from './x'
import { youtubeSearchTool } from './youtube/search'
import { sendSMSTool } from './twilio/sendSMS'
import { airtableReadTool, airtableWriteTool, airtableUpdateTool } from './airtable'
const logger = createLogger('Tools')
+1 -1
View File
@@ -1,3 +1,3 @@
import { sendSMSTool } from './sendSMS'
import { sendSMSTool } from './send'
export const twilioSendSMSTool = sendSMSTool
@@ -1,8 +1,8 @@
import { createLogger } from '@/lib/logs/console-logger'
import { ToolConfig } from '../types'
import { TwilioSMSBlockOutput, TwilioSendSMSParams } from './types'
import { TwilioSendSMSParams, TwilioSMSBlockOutput } from './types'
const logger = createLogger('Twilio Send SMS Tool')
const logger = createLogger('Twilio Send SMS Tool')
export const sendSMSTool: ToolConfig<TwilioSendSMSParams, TwilioSMSBlockOutput> = {
id: 'twilio_send_sms',
@@ -14,30 +14,30 @@ export const sendSMSTool: ToolConfig<TwilioSendSMSParams, TwilioSMSBlockOutput>
phoneNumbers: {
type: 'string',
required: true,
description: 'Phone numbers to send the message to, separated by newlines'
description: 'Phone numbers to send the message to, separated by newlines',
},
message: {
type: 'string',
required: true,
description: 'Message to send'
description: 'Message to send',
},
accountSid: {
type: 'string',
required: true,
description: 'Twilio Account SID',
requiredForToolCall: true
requiredForToolCall: true,
},
authToken: {
type: 'string',
required: true,
description: 'Twilio Auth Token',
requiredForToolCall: true
requiredForToolCall: true,
},
fromNumber: {
type: 'string',
required: true,
description: 'Twilio phone number to send the message from'
}
description: 'Twilio phone number to send the message from',
},
},
request: {
@@ -45,7 +45,7 @@ export const sendSMSTool: ToolConfig<TwilioSendSMSParams, TwilioSMSBlockOutput>
if (!params.accountSid) {
throw new Error('Twilio Account SID is required')
}
const url = `https://api.twilio.com/2010-04-01/Accounts/${params.accountSid}/Messages.json`;
const url = `https://api.twilio.com/2010-04-01/Accounts/${params.accountSid}/Messages.json`
return url
},
method: 'POST',
@@ -57,7 +57,7 @@ export const sendSMSTool: ToolConfig<TwilioSendSMSParams, TwilioSMSBlockOutput>
const authToken = Buffer.from(`${params.accountSid}:${params.authToken}`).toString('base64')
const headers = {
Authorization: `Basic ${authToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
'Content-Type': 'application/x-www-form-urlencoded',
}
return headers
},
@@ -74,27 +74,28 @@ export const sendSMSTool: ToolConfig<TwilioSendSMSParams, TwilioSMSBlockOutput>
// Get first phone number if multiple are provided
const toNumber = params.phoneNumbers.split('\n')[0].trim()
// Create a URLSearchParams object and convert to string
const formData = new URLSearchParams()
formData.append('To', toNumber)
formData.append('From', params.fromNumber)
formData.append('Body', params.message)
const formDataString = formData.toString()
return { body: formDataString }
}
},
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
const errorMessage = data.error?.message || data.message || `Failed to send SMS (HTTP ${response.status})`
const errorMessage =
data.error?.message || data.message || `Failed to send SMS (HTTP ${response.status})`
logger.error('Twilio API error:', data)
throw new Error(errorMessage)
}
logger.info('Twilio Response:', data)
logger.info('Twilio Response type:', typeof data)
return {
@@ -102,14 +103,14 @@ export const sendSMSTool: ToolConfig<TwilioSendSMSParams, TwilioSMSBlockOutput>
output: {
success: true,
messageId: data.sid,
status: data.status
status: data.status,
},
error: undefined
error: undefined,
}
},
transformError: (error) => {
logger.error('Twilio tool error:', { error })
return `SMS sending failed: ${error.message || 'Unknown error occurred'}`
}
}
},
}
+4 -4
View File
@@ -10,9 +10,9 @@ export interface TwilioSendSMSParams {
export interface TwilioSMSBlockOutput extends ToolResponse {
output: {
success: boolean
messageId?: string
status?: string
error?: string
success: boolean
messageId?: string
status?: string
error?: string
}
}
+13 -13
View File
@@ -16,7 +16,7 @@ describe('Typeform Files Tool', () => {
// Mock file response
const mockFileResponseHeaders = {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="test-file.pdf"'
'content-disposition': 'attachment; filename="test-file.pdf"',
}
beforeEach(() => {
@@ -35,7 +35,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
apiKey: 'test-token',
}
expect(tester.getRequestUrl(params)).toBe(
@@ -50,7 +50,7 @@ describe('Typeform Files Tool', () => {
fieldId: 'field789',
filename: 'test-file.pdf',
inline: true,
apiKey: 'test-token'
apiKey: 'test-token',
}
const url = tester.getRequestUrl(params)
@@ -63,7 +63,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp&with#chars',
fieldId: 'field-id',
filename: 'file name.pdf',
apiKey: 'test-token'
apiKey: 'test-token',
}
const url = tester.getRequestUrl(params)
@@ -80,7 +80,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
apiKey: 'test-token',
}
const headers = tester.getRequestHeaders(params)
@@ -93,7 +93,7 @@ describe('Typeform Files Tool', () => {
test('should transform file data correctly', async () => {
// Setup mock response for binary file data
tester.setup('file-content-binary-data', {
headers: mockFileResponseHeaders
headers: mockFileResponseHeaders,
})
// Execute the tool
@@ -102,7 +102,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
apiKey: 'test-token',
})
// Check the result
@@ -115,7 +115,7 @@ describe('Typeform Files Tool', () => {
test('should handle missing content-disposition header', async () => {
// Setup mock response without content-disposition
tester.setup('file-content-binary-data', {
headers: { 'content-type': 'application/pdf' }
headers: { 'content-type': 'application/pdf' },
})
// Execute the tool
@@ -124,7 +124,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
apiKey: 'test-token',
})
// Check the result
@@ -147,7 +147,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp456',
fieldId: 'field789',
filename: 'nonexistent.pdf',
apiKey: 'test-token'
apiKey: 'test-token',
})
// Check error handling
@@ -165,7 +165,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'invalid-token'
apiKey: 'invalid-token',
})
// Check error handling
@@ -183,7 +183,7 @@ describe('Typeform Files Tool', () => {
responseId: 'resp456',
fieldId: 'field789',
filename: 'test-file.pdf',
apiKey: 'test-token'
apiKey: 'test-token',
})
// Check error handling
@@ -191,4 +191,4 @@ describe('Typeform Files Tool', () => {
expect(result.error).toBeDefined()
})
})
})
})
+37 -37
View File
@@ -60,88 +60,88 @@ export const filesTool: ToolConfig<TypeformFilesParams, TypeformFilesResponse> =
const encodedResponseId = encodeURIComponent(params.responseId)
const encodedFieldId = encodeURIComponent(params.fieldId)
const encodedFilename = encodeURIComponent(params.filename)
let url = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}`
// Add the inline parameter if provided
if (params.inline !== undefined) {
url += `?inline=${params.inline}`
}
return url
},
method: 'GET',
headers: (params) => ({
'Authorization': `Bearer ${params.apiKey}`,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params?: TypeformFilesParams) => {
if (!response.ok) {
let errorMessage = response.statusText || 'Unknown error';
let errorMessage = response.statusText || 'Unknown error'
try {
const errorData = await response.json();
const errorData = await response.json()
if (errorData && errorData.message) {
errorMessage = errorData.message;
errorMessage = errorData.message
} else if (errorData && errorData.description) {
errorMessage = errorData.description;
errorMessage = errorData.description
} else if (typeof errorData === 'string') {
errorMessage = errorData;
errorMessage = errorData
}
} catch (e) {
// If we can't parse the error as JSON, just use the status text
}
throw new Error(`Typeform API error (${response.status}): ${errorMessage}`);
throw new Error(`Typeform API error (${response.status}): ${errorMessage}`)
}
// For file downloads, we get the file directly
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = response.headers.get('content-disposition') || '';
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const contentDisposition = response.headers.get('content-disposition') || ''
// Try to extract filename from content-disposition if possible
let filename = '';
const filenameMatch = contentDisposition.match(/filename="(.+?)"/);
let filename = ''
const filenameMatch = contentDisposition.match(/filename="(.+?)"/)
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
filename = filenameMatch[1]
}
// Get file URL from the response URL or construct it from parameters if not available
let fileUrl = response.url;
let fileUrl = response.url
// If the response URL is not available (common in test environments), construct it from params
if (!fileUrl && params) {
const encodedFormId = encodeURIComponent(params.formId);
const encodedResponseId = encodeURIComponent(params.responseId);
const encodedFieldId = encodeURIComponent(params.fieldId);
const encodedFilename = encodeURIComponent(params.filename);
fileUrl = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}`;
const encodedFormId = encodeURIComponent(params.formId)
const encodedResponseId = encodeURIComponent(params.responseId)
const encodedFieldId = encodeURIComponent(params.fieldId)
const encodedFilename = encodeURIComponent(params.filename)
fileUrl = `https://api.typeform.com/forms/${encodedFormId}/responses/${encodedResponseId}/fields/${encodedFieldId}/files/${encodedFilename}`
if (params.inline !== undefined) {
fileUrl += `?inline=${params.inline}`;
fileUrl += `?inline=${params.inline}`
}
}
return {
success: true,
output: {
fileUrl: fileUrl || '',
contentType,
filename
}
};
filename,
},
}
},
transformError: (error) => {
if (error instanceof Error) {
return `Failed to retrieve Typeform file: ${error.message}`
}
if (typeof error === 'object' && error !== null) {
return `Failed to retrieve Typeform file: ${JSON.stringify(error)}`
}
return `Failed to retrieve Typeform file: An unknown error occurred`
},
}
}
+67 -67
View File
@@ -7,26 +7,26 @@
* work correctly together and can be properly used from the block.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { typeformFilesTool, typeformResponsesTool, typeformInsightsTool } from './index'
import { ToolTester } from '../__test-utils__/test-tools'
import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from './index'
describe('Typeform Tools Integration', () => {
describe('Typeform Responses Tool Export', () => {
let tester: ToolTester
beforeEach(() => {
tester = new ToolTester(typeformResponsesTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
test('should use the correct tool ID', () => {
expect(typeformResponsesTool.id).toBe('typeform_responses')
})
test('should handle basic responses request', async () => {
// Setup mock response data
const mockData = {
@@ -41,45 +41,45 @@ describe('Typeform Tools Integration', () => {
},
],
}
tester.setup(mockData)
// Execute the tool
const result = await tester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(result.success).toBe(true)
expect(result.output.total_items).toBe(1)
})
})
describe('Typeform Files Tool Export', () => {
let tester: ToolTester
beforeEach(() => {
tester = new ToolTester(typeformFilesTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
test('should use the correct tool ID', () => {
expect(typeformFilesTool.id).toBe('typeform_files')
})
test('should handle basic file request', async () => {
// Setup mock response with file headers
tester.setup('binary-file-content', {
headers: {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="test.pdf"'
}
'content-disposition': 'attachment; filename="test.pdf"',
},
})
// Execute the tool
const result = await tester.execute({
formId: 'test-form',
@@ -88,29 +88,29 @@ describe('Typeform Tools Integration', () => {
filename: 'test.pdf',
apiKey: 'test-api-key',
})
expect(result.success).toBe(true)
expect(result.output.contentType).toBe('application/pdf')
expect(result.output.filename).toBe('test.pdf')
})
})
describe('Typeform Insights Tool Export', () => {
let tester: ToolTester
beforeEach(() => {
tester = new ToolTester(typeformInsightsTool)
})
afterEach(() => {
tester.cleanup()
vi.resetAllMocks()
})
test('should use the correct tool ID', () => {
expect(typeformInsightsTool.id).toBe('typeform_insights')
})
test('should handle basic insights request', async () => {
// Setup mock response data
const mockData = {
@@ -122,8 +122,8 @@ describe('Typeform Tools Integration', () => {
ref: 'ref123',
title: 'What is your name?',
type: 'short_text',
views: 100
}
views: 100,
},
],
form: {
platforms: [
@@ -133,40 +133,40 @@ describe('Typeform Tools Integration', () => {
platform: 'desktop',
responses_count: 80,
total_visits: 120,
unique_visits: 100
}
unique_visits: 100,
},
],
summary: {
average_time: 140000,
completion_rate: 72.3,
responses_count: 120,
total_visits: 180,
unique_visits: 150
}
}
unique_visits: 150,
},
},
}
tester.setup(mockData)
// Execute the tool
const result = await tester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(result.success).toBe(true)
expect(result.output.form.summary.responses_count).toBe(120)
expect(result.output.fields).toHaveLength(1)
})
})
describe('End-to-End Flow', () => {
// This test simulates using both tools together in a workflow
test('should be able to get responses and then file', async () => {
// First set up responses tester
const responsesTester = new ToolTester(typeformResponsesTool)
// Mock responses data with a file upload
const mockResponsesData = {
total_items: 1,
@@ -189,35 +189,35 @@ describe('Typeform Tools Integration', () => {
},
],
}
responsesTester.setup(mockResponsesData)
// Get responses
const responsesResult = await responsesTester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(responsesResult.success).toBe(true)
// Now get the response ID and field ID
const responseId = responsesResult.output.items[0].token
expect(responseId).toBe('response-id')
const fieldId = responsesResult.output.items[0].answers[0].field.id
expect(fieldId).toBe('file-field')
// Now set up files tester
const filesTester = new ToolTester(typeformFilesTool)
// Mock file data
filesTester.setup('binary-file-data', {
headers: {
'content-type': 'application/pdf',
'content-disposition': 'attachment; filename="uploaded.pdf"'
}
'content-disposition': 'attachment; filename="uploaded.pdf"',
},
})
// Get file using the response ID and field ID from previous request
const filesResult = await filesTester.execute({
formId: 'test-form',
@@ -226,20 +226,20 @@ describe('Typeform Tools Integration', () => {
filename: 'uploaded.pdf',
apiKey: 'test-api-key',
})
expect(filesResult.success).toBe(true)
expect(filesResult.output.contentType).toBe('application/pdf')
expect(filesResult.output.filename).toBe('uploaded.pdf')
// Clean up
responsesTester.cleanup()
filesTester.cleanup()
})
test('should be able to get responses and then insights', async () => {
// First set up responses tester
const responsesTester = new ToolTester(typeformResponsesTool)
// Mock responses data
const mockResponsesData = {
total_items: 10,
@@ -253,21 +253,21 @@ describe('Typeform Tools Integration', () => {
},
],
}
responsesTester.setup(mockResponsesData)
// Get responses
const responsesResult = await responsesTester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(responsesResult.success).toBe(true)
expect(responsesResult.output.total_items).toBe(10)
// Now set up insights tester
const insightsTester = new ToolTester(typeformInsightsTool)
// Mock insights data
const mockInsightsData = {
fields: [
@@ -278,8 +278,8 @@ describe('Typeform Tools Integration', () => {
ref: 'ref123',
title: 'What is your name?',
type: 'short_text',
views: 100
}
views: 100,
},
],
form: {
platforms: [
@@ -289,37 +289,37 @@ describe('Typeform Tools Integration', () => {
platform: 'desktop',
responses_count: 80,
total_visits: 120,
unique_visits: 100
}
unique_visits: 100,
},
],
summary: {
average_time: 140000,
completion_rate: 72.3,
responses_count: 120,
total_visits: 180,
unique_visits: 150
}
}
unique_visits: 150,
},
},
}
insightsTester.setup(mockInsightsData)
// Get insights for the same form
const insightsResult = await insightsTester.execute({
formId: 'test-form',
apiKey: 'test-api-key',
})
expect(insightsResult.success).toBe(true)
expect(insightsResult.output.form.summary.responses_count).toBe(120)
// Verify we can analyze the data by looking at completion rates
expect(insightsResult.output.form.summary.completion_rate).toBe(72.3)
expect(insightsResult.output.form.platforms[0].platform).toBe('desktop')
// Clean up
responsesTester.cleanup()
insightsTester.cleanup()
})
})
})
})
+2 -2
View File
@@ -1,7 +1,7 @@
import { responsesTool } from './responses'
import { filesTool } from './files'
import { insightsTool } from './insights'
import { responsesTool } from './responses'
export const typeformResponsesTool = responsesTool
export const typeformFilesTool = filesTool
export const typeformInsightsTool = insightsTool
export const typeformInsightsTool = insightsTool
+21 -23
View File
@@ -23,7 +23,7 @@ describe('Typeform Insights Tool', () => {
ref: 'ref123',
title: 'What is your name?',
type: 'short_text',
views: 100
views: 100,
},
{
dropoffs: 10,
@@ -32,8 +32,8 @@ describe('Typeform Insights Tool', () => {
ref: 'ref456',
title: 'How did you hear about us?',
type: 'multiple_choice',
views: 95
}
views: 95,
},
],
form: {
platforms: [
@@ -43,7 +43,7 @@ describe('Typeform Insights Tool', () => {
platform: 'desktop',
responses_count: 80,
total_visits: 120,
unique_visits: 100
unique_visits: 100,
},
{
average_time: 180000,
@@ -51,17 +51,17 @@ describe('Typeform Insights Tool', () => {
platform: 'mobile',
responses_count: 40,
total_visits: 60,
unique_visits: 50
}
unique_visits: 50,
},
],
summary: {
average_time: 140000,
completion_rate: 72.3,
responses_count: 120,
total_visits: 180,
unique_visits: 150
}
}
unique_visits: 150,
},
},
}
beforeEach(() => {
@@ -77,18 +77,16 @@ describe('Typeform Insights Tool', () => {
test('should construct correct URL for insights endpoint', () => {
const params = {
formId: 'form123',
apiKey: 'test-token'
apiKey: 'test-token',
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/insights/form123/summary'
)
expect(tester.getRequestUrl(params)).toBe('https://api.typeform.com/insights/form123/summary')
})
test('should handle special characters in form ID', () => {
const params = {
formId: 'form/with/special?chars',
apiKey: 'test-token'
apiKey: 'test-token',
}
const url = tester.getRequestUrl(params)
@@ -102,7 +100,7 @@ describe('Typeform Insights Tool', () => {
test('should include correct authorization header', () => {
const params = {
formId: 'form123',
apiKey: 'test-token'
apiKey: 'test-token',
}
const headers = tester.getRequestHeaders(params)
@@ -119,21 +117,21 @@ describe('Typeform Insights Tool', () => {
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'test-token'
apiKey: 'test-token',
})
// Check the result
expect(result.success).toBe(true)
// Verify form summary data
expect(result.output.form.summary.responses_count).toBe(120)
expect(result.output.form.summary.completion_rate).toBe(72.3)
// Verify platforms data
expect(result.output.form.platforms).toHaveLength(2)
expect(result.output.form.platforms[0].platform).toBe('desktop')
expect(result.output.form.platforms[1].platform).toBe('mobile')
// Verify fields data
expect(result.output.fields).toHaveLength(2)
expect(result.output.fields[0].title).toBe('What is your name?')
@@ -149,7 +147,7 @@ describe('Typeform Insights Tool', () => {
// Execute the tool
const result = await tester.execute({
formId: 'nonexistent',
apiKey: 'test-token'
apiKey: 'test-token',
})
// Check error handling
@@ -164,7 +162,7 @@ describe('Typeform Insights Tool', () => {
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'invalid-token'
apiKey: 'invalid-token',
})
// Check error handling
@@ -179,7 +177,7 @@ describe('Typeform Insights Tool', () => {
// Execute the tool
const result = await tester.execute({
formId: 'form123',
apiKey: 'test-token'
apiKey: 'test-token',
})
// Check error handling
@@ -187,4 +185,4 @@ describe('Typeform Insights Tool', () => {
expect(result.error).toBeDefined()
})
})
})
})
+26 -26
View File
@@ -64,32 +64,32 @@ export const insightsTool: ToolConfig<TypeformInsightsParams, TypeformInsightsRe
},
method: 'GET',
headers: (params) => ({
'Authorization': `Bearer ${params.apiKey}`,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
let errorMessage = response.statusText || 'Unknown error';
let errorDetails = '';
let errorMessage = response.statusText || 'Unknown error'
let errorDetails = ''
try {
const errorData = await response.json();
console.log('Typeform API error response:', JSON.stringify(errorData, null, 2));
const errorData = await response.json()
console.log('Typeform API error response:', JSON.stringify(errorData, null, 2))
if (errorData && errorData.message) {
errorMessage = errorData.message;
errorMessage = errorData.message
} else if (errorData && errorData.description) {
errorMessage = errorData.description;
errorMessage = errorData.description
} else if (typeof errorData === 'string') {
errorMessage = errorData;
errorMessage = errorData
}
// Extract more details if available
if (errorData && errorData.details) {
errorDetails = ` Details: ${JSON.stringify(errorData.details)}`;
errorDetails = ` Details: ${JSON.stringify(errorData.details)}`
}
// Special handling for 403 errors
if (response.status === 403) {
return {
@@ -100,33 +100,33 @@ export const insightsTool: ToolConfig<TypeformInsightsParams, TypeformInsightsRe
2. Insufficient plan subscription (insights may require a higher plan)
3. No access rights to the specified form
4. API token is invalid or expired
Details from API: ${errorMessage}${errorDetails}`
};
Details from API: ${errorMessage}${errorDetails}`,
}
}
} catch (e) {
// If we can't parse the error as JSON, just use the status text
console.log('Error parsing Typeform API error:', e);
console.log('Error parsing Typeform API error:', e)
}
throw new Error(`Typeform API error (${response.status}): ${errorMessage}${errorDetails}`);
throw new Error(`Typeform API error (${response.status}): ${errorMessage}${errorDetails}`)
}
const data = await response.json();
const data = await response.json()
return {
success: true,
output: data
};
output: data,
}
},
transformError: (error) => {
if (error instanceof Error) {
return `Failed to retrieve Typeform insights: ${error.message}`
}
if (typeof error === 'object' && error !== null) {
return `Failed to retrieve Typeform insights: ${JSON.stringify(error)}`
}
return `Failed to retrieve Typeform insights: An unknown error occurred`
},
}
}
+5 -9
View File
@@ -95,9 +95,7 @@ describe('Typeform Responses Tool', () => {
apiKey: 'test-token',
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/forms/form123/responses'
)
expect(tester.getRequestUrl(params)).toBe('https://api.typeform.com/forms/form123/responses')
})
test('should add pageSize parameter to URL when provided', () => {
@@ -155,9 +153,7 @@ describe('Typeform Responses Tool', () => {
completed: 'all',
}
expect(tester.getRequestUrl(params)).toBe(
'https://api.typeform.com/forms/form123/responses'
)
expect(tester.getRequestUrl(params)).toBe('https://api.typeform.com/forms/form123/responses')
})
test('should combine multiple parameters correctly', () => {
@@ -207,13 +203,13 @@ describe('Typeform Responses Tool', () => {
expect(result.success).toBe(true)
expect(result.output.total_items).toBe(2)
expect(result.output.items).toHaveLength(2)
// Check first response
const firstResponse = result.output.items[0]
expect(firstResponse.token).toBe('response-id-1')
expect(firstResponse.answers).toHaveLength(1)
expect(firstResponse.answers[0].text).toBe('Sample answer')
// Check second response
const secondResponse = result.output.items[1]
expect(secondResponse.token).toBe('response-id-2')
@@ -266,4 +262,4 @@ describe('Typeform Responses Tool', () => {
expect(result.error).toBeDefined()
})
})
})
})
+25 -23
View File
@@ -87,73 +87,75 @@ export const responsesTool: ToolConfig<TypeformResponsesParams, TypeformResponse
request: {
url: (params: TypeformResponsesParams) => {
const url = `https://api.typeform.com/forms/${params.formId}/responses`
const queryParams = []
if (params.pageSize) {
queryParams.push(`page_size=${params.pageSize}`)
}
if (params.since) {
queryParams.push(`since=${encodeURIComponent(params.since)}`)
}
if (params.until) {
queryParams.push(`until=${encodeURIComponent(params.until)}`)
}
if (params.completed && params.completed !== 'all') {
queryParams.push(`completed=${params.completed}`)
}
return queryParams.length > 0 ? `${url}?${queryParams.join('&')}` : url
},
method: 'GET',
headers: (params) => ({
'Authorization': `Bearer ${params.apiKey}`,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
let errorMessage = response.statusText || 'Unknown error';
let errorMessage = response.statusText || 'Unknown error'
try {
const errorData = await response.json();
const errorData = await response.json()
if (errorData && errorData.message) {
errorMessage = errorData.message;
errorMessage = errorData.message
} else if (errorData && errorData.description) {
errorMessage = errorData.description;
errorMessage = errorData.description
} else if (typeof errorData === 'string') {
errorMessage = errorData;
errorMessage = errorData
}
} catch (e) {
// If we can't parse the error as JSON, just use the status text
}
throw new Error(`Typeform API error (${response.status}): ${errorMessage}`);
throw new Error(`Typeform API error (${response.status}): ${errorMessage}`)
}
try {
const data = await response.json();
const data = await response.json()
return {
success: true,
output: data,
};
}
} catch (error) {
throw new Error(`Failed to parse Typeform response: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(
`Failed to parse Typeform response: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
},
transformError: (error) => {
if (error instanceof Error) {
return `Failed to retrieve Typeform responses: ${error.message}`
}
if (typeof error === 'object' && error !== null) {
return `Failed to retrieve Typeform responses: ${JSON.stringify(error)}`
}
return `Failed to retrieve Typeform responses: An unknown error occurred`
},
}
}
+3 -1
View File
@@ -48,7 +48,9 @@ export function formatRequestParams(tool: ToolConfig, params: Record<string, any
const bodyResult = tool.request.body ? tool.request.body(params) : undefined
// Special handling for NDJSON content type or 'application/x-www-form-urlencoded'
const isPreformattedContent = headers['Content-Type'] === 'application/x-ndjson' || headers['Content-Type'] === 'application/x-www-form-urlencoded'
const isPreformattedContent =
headers['Content-Type'] === 'application/x-ndjson' ||
headers['Content-Type'] === 'application/x-www-form-urlencoded'
const body = hasBody
? isPreformattedContent && bodyResult
? bodyResult.body