diff --git a/apps/docs/content/docs/en/tools/gmail.mdx b/apps/docs/content/docs/en/tools/gmail.mdx index 70ea404e21..447f870aac 100644 --- a/apps/docs/content/docs/en/tools/gmail.mdx +++ b/apps/docs/content/docs/en/tools/gmail.mdx @@ -66,8 +66,10 @@ Send emails using Gmail | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `to` | string | Yes | Recipient email address | -| `subject` | string | Yes | Email subject | +| `subject` | string | No | Email subject | | `body` | string | Yes | Email body content | +| `threadId` | string | No | Thread ID to reply to \(for threading\) | +| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not the RFC "messageId"\) | | `cc` | string | No | CC recipients \(comma-separated\) | | `bcc` | string | No | BCC recipients \(comma-separated\) | | `attachments` | file[] | No | Files to attach to the email | @@ -88,8 +90,10 @@ Draft emails using Gmail | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `to` | string | Yes | Recipient email address | -| `subject` | string | Yes | Email subject | +| `subject` | string | No | Email subject | | `body` | string | Yes | Email body content | +| `threadId` | string | No | Thread ID to reply to \(for threading\) | +| `replyToMessageId` | string | No | Gmail message ID to reply to - use the "id" field from Gmail Read results \(not the RFC "messageId"\) | | `cc` | string | No | CC recipients \(comma-separated\) | | `bcc` | string | No | BCC recipients \(comma-separated\) | | `attachments` | file[] | No | Files to attach to the email draft | diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 992b6d4b01..7884c39901 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -63,6 +63,7 @@ "thinking", "translate", "twilio_sms", + "twilio_voice", "typeform", "vision", "wealthbox", diff --git a/apps/docs/content/docs/en/tools/microsoft_teams.mdx b/apps/docs/content/docs/en/tools/microsoft_teams.mdx index cb61f0daec..47eaa424c5 100644 --- a/apps/docs/content/docs/en/tools/microsoft_teams.mdx +++ b/apps/docs/content/docs/en/tools/microsoft_teams.mdx @@ -98,7 +98,7 @@ In Sim, the Microsoft Teams integration enables your agents to interact directly ## Usage Instructions -Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel. +Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel. To mention users in messages, wrap their name in `` tags: `userName` @@ -208,13 +208,3 @@ Write or send a message to a Microsoft Teams channel - Category: `tools` - Type: `microsoft_teams` - -### Mentioning Users - -To mention users in your messages (both in chats and channels), wrap their display name in `` tags: - -``` -John Doe can you review this? -``` - -The mention will be automatically resolved to the correct user and they will receive a notification in Microsoft Teams. This works for both chat messages and channel messages. Bots/Apps cannot be tagged. diff --git a/apps/docs/content/docs/en/tools/twilio_voice.mdx b/apps/docs/content/docs/en/tools/twilio_voice.mdx new file mode 100644 index 0000000000..13ecb049df --- /dev/null +++ b/apps/docs/content/docs/en/tools/twilio_voice.mdx @@ -0,0 +1,146 @@ +--- +title: Twilio Voice +description: Make and manage phone calls +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + + `} +/> + +{/* MANUAL-CONTENT-START:intro */} +[Twilio Voice](https://www.twilio.com/en-us/voice) is a powerful cloud communications platform that enables businesses to make, receive, and manage phone calls programmatically through a simple API. + +Twilio Voice provides a robust API for building sophisticated voice applications with global reach. With coverage in over 100 countries, carrier-grade reliability, and a 99.95% uptime SLA, Twilio has established itself as the industry leader in programmable voice communications. + +Key features of Twilio Voice include: + +- **Global Voice Network**: Make and receive calls worldwide with local phone numbers in multiple countries +- **Programmable Call Control**: Use TwiML to control call flow, record conversations, gather DTMF input, and implement IVR systems +- **Advanced Capabilities**: Speech recognition, text-to-speech, call forwarding, conferencing, and answering machine detection +- **Real-time Analytics**: Track call quality, duration, costs, and optimize your voice applications + +In Sim, the Twilio Voice integration enables your agents to leverage these powerful voice capabilities as part of their workflows. This creates opportunities for sophisticated customer engagement scenarios like appointment reminders, verification calls, automated support lines, and interactive voice response systems. The integration bridges the gap between your AI workflows and voice communication channels, enabling your agents to deliver timely, relevant information directly through phone calls. By connecting Sim with Twilio Voice, you can create intelligent agents that engage customers through their preferred communication channel, enhancing the user experience while automating routine calling tasks. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate Twilio Voice into the workflow. Make outbound calls and retrieve call recordings. + + + +## Tools + +### `twilio_voice_make_call` + +Make an outbound phone call using Twilio Voice API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `to` | string | Yes | Phone number to call \(E.164 format, e.g., +14155551234\) | +| `from` | string | Yes | Your Twilio phone number to call from \(E.164 format\) | +| `url` | string | No | URL that returns TwiML instructions for the call | +| `twiml` | string | No | TwiML instructions to execute \(alternative to URL\). Use square brackets instead of angle brackets, e.g., \[Response\]\[Say\]Hello\[/Say\]\[/Response\] | +| `statusCallback` | string | No | Webhook URL for call status updates | +| `statusCallbackMethod` | string | No | HTTP method for status callback \(GET or POST\) | +| `accountSid` | string | Yes | Twilio Account SID | +| `authToken` | string | Yes | Twilio Auth Token | +| `record` | boolean | No | Whether to record the call | +| `recordingStatusCallback` | string | No | Webhook URL for recording status updates | +| `timeout` | number | No | Time to wait for answer before giving up \(seconds, default: 60\) | +| `machineDetection` | string | No | Answering machine detection: Enable or DetectMessageEnd | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the call was successfully initiated | +| `callSid` | string | Unique identifier for the call | +| `status` | string | Call status \(queued, ringing, in-progress, completed, etc.\) | +| `direction` | string | Call direction \(outbound-api\) | +| `from` | string | Phone number the call is from | +| `to` | string | Phone number the call is to | +| `duration` | number | Call duration in seconds | +| `price` | string | Cost of the call | +| `priceUnit` | string | Currency of the price | +| `error` | string | Error message if call failed | + +### `twilio_voice_list_calls` + +Retrieve a list of calls made to and from an account. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accountSid` | string | Yes | Twilio Account SID | +| `authToken` | string | Yes | Twilio Auth Token | +| `to` | string | No | Filter by calls to this phone number | +| `from` | string | No | Filter by calls from this phone number | +| `status` | string | No | Filter by call status \(queued, ringing, in-progress, completed, etc.\) | +| `startTimeAfter` | string | No | Filter calls that started on or after this date \(YYYY-MM-DD\) | +| `startTimeBefore` | string | No | Filter calls that started on or before this date \(YYYY-MM-DD\) | +| `pageSize` | number | No | Number of records to return \(max 1000, default 50\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the calls were successfully retrieved | +| `calls` | array | Array of call objects | +| `total` | number | Total number of calls returned | +| `page` | number | Current page number | +| `pageSize` | number | Number of calls per page | +| `error` | string | Error message if retrieval failed | + +### `twilio_voice_get_recording` + +Retrieve call recording information and transcription (if enabled via TwiML). + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `recordingSid` | string | Yes | Recording SID to retrieve | +| `accountSid` | string | Yes | Twilio Account SID | +| `authToken` | string | Yes | Twilio Auth Token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Whether the recording was successfully retrieved | +| `recordingSid` | string | Unique identifier for the recording | +| `callSid` | string | Call SID this recording belongs to | +| `duration` | number | Duration of the recording in seconds | +| `status` | string | Recording status \(completed, processing, etc.\) | +| `channels` | number | Number of channels \(1 for mono, 2 for dual\) | +| `source` | string | How the recording was created | +| `mediaUrl` | string | URL to download the recording media file | +| `price` | string | Cost of the recording | +| `priceUnit` | string | Currency of the price | +| `uri` | string | Relative URI of the recording resource | +| `transcriptionText` | string | Transcribed text from the recording \(if available\) | +| `transcriptionStatus` | string | Transcription status \(completed, in-progress, failed\) | +| `transcriptionPrice` | string | Cost of the transcription | +| `transcriptionPriceUnit` | string | Currency of the transcription price | +| `error` | string | Error message if retrieval failed | + + + +## Notes + +- Category: `tools` +- Type: `twilio_voice` diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index a6ca7dcd08..c8eda167ba 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -417,7 +417,7 @@ export async function POST(request: NextRequest) { if (savedWebhook && provider === 'gmail') { logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) try { - const { configureGmailPolling } = await import('@/lib/webhooks/utils') + const { configureGmailPolling } = await import('@/lib/webhooks/utils.server') const success = await configureGmailPolling(savedWebhook, requestId) if (!success) { @@ -456,7 +456,7 @@ export async function POST(request: NextRequest) { `[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.` ) try { - const { configureOutlookPolling } = await import('@/lib/webhooks/utils') + const { configureOutlookPolling } = await import('@/lib/webhooks/utils.server') const success = await configureOutlookPolling(savedWebhook, requestId) if (!success) { diff --git a/apps/sim/app/api/webhooks/test/[id]/route.ts b/apps/sim/app/api/webhooks/test/[id]/route.ts index 33f9aa59bd..6601745781 100644 --- a/apps/sim/app/api/webhooks/test/[id]/route.ts +++ b/apps/sim/app/api/webhooks/test/[id]/route.ts @@ -56,7 +56,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { webhook: foundWebhook, workflow: foundWorkflow } = result - const authError = await verifyProviderAuth(foundWebhook, request, rawBody, requestId) + const authError = await verifyProviderAuth( + foundWebhook, + foundWorkflow, + request, + rawBody, + requestId + ) if (authError) { return authError } diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 17fb77d7e0..5d519eceef 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -91,7 +91,13 @@ export async function POST( const { webhook: foundWebhook, workflow: foundWorkflow } = findResult - const authError = await verifyProviderAuth(foundWebhook, request, rawBody, requestId) + const authError = await verifyProviderAuth( + foundWebhook, + foundWorkflow, + request, + rawBody, + requestId + ) if (authError) { return authError } diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index 9681f0e232..2dc7a905f7 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -11,7 +11,7 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { decryptSecret } from '@/lib/utils' import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' -import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webhooks/utils' +import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webhooks/utils.server' import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, @@ -263,7 +263,7 @@ async function executeWebhookJobInternal( metadata, workflow, airtableInput, - {}, + decryptedEnvVars, workflow.variables || {}, [] ) @@ -449,7 +449,7 @@ async function executeWebhookJobInternal( metadata, workflow, input || {}, - {}, + decryptedEnvVars, workflow.variables || {}, [] ) diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index 3c50aed2f4..8317237d61 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -10,7 +10,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { description: 'Read, write, and create messages', authMode: AuthMode.OAuth, longDescription: - 'Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel. To mention users in messages, wrap their name in tags: userName', + 'Integrate Microsoft Teams into the workflow. Can read and write chat messages, and read and write channel messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat or channel. To mention users in messages, wrap their name in `` tags: `userName`', docsLink: 'https://docs.sim.ai/tools/microsoft_teams', category: 'tools', triggerAllowed: true, diff --git a/apps/sim/blocks/blocks/twilio_voice.ts b/apps/sim/blocks/blocks/twilio_voice.ts new file mode 100644 index 0000000000..d049939db7 --- /dev/null +++ b/apps/sim/blocks/blocks/twilio_voice.ts @@ -0,0 +1,344 @@ +import { TwilioIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode } from '@/blocks/types' +import type { ToolResponse } from '@/tools/types' +import { getTrigger } from '@/triggers' + +export const TwilioVoiceBlock: BlockConfig = { + type: 'twilio_voice', + name: 'Twilio Voice', + description: 'Make and manage phone calls', + authMode: AuthMode.ApiKey, + longDescription: + 'Integrate Twilio Voice into the workflow. Make outbound calls and retrieve call recordings.', + category: 'tools', + bgColor: '#F22F46', // Twilio brand color + icon: TwilioIcon, + triggerAllowed: true, + subBlocks: [ + ...getTrigger('twilio_voice_webhook').subBlocks, + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Make Call', id: 'make_call' }, + { label: 'List Calls', id: 'list_calls' }, + { label: 'Get Recording', id: 'get_recording' }, + ], + value: () => 'make_call', + }, + { + id: 'accountSid', + title: 'Twilio Account SID', + type: 'short-input', + layout: 'full', + placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + required: true, + }, + { + id: 'authToken', + title: 'Auth Token', + type: 'short-input', + layout: 'full', + placeholder: 'Your Twilio Auth Token', + password: true, + required: true, + }, + { + id: 'to', + title: 'To Phone Number', + type: 'short-input', + layout: 'half', + placeholder: '+14155551234', + condition: { + field: 'operation', + value: 'make_call', + }, + required: true, + }, + { + id: 'from', + title: 'From Twilio Number', + type: 'short-input', + layout: 'half', + placeholder: '+14155556789', + condition: { + field: 'operation', + value: 'make_call', + }, + required: true, + }, + { + id: 'url', + title: 'TwiML URL', + type: 'short-input', + layout: 'full', + placeholder: 'https://example.com/twiml', + condition: { + field: 'operation', + value: 'make_call', + }, + }, + { + id: 'twiml', + title: 'TwiML Instructions', + type: 'long-input', + layout: 'full', + placeholder: '[Response][Say]Hello from Twilio![/Say][/Response]', + description: + 'Use square brackets instead of angle brackets (e.g., [Response] instead of )', + condition: { + field: 'operation', + value: 'make_call', + }, + }, + { + id: 'record', + title: 'Record Call', + type: 'switch', + layout: 'half', + condition: { + field: 'operation', + value: 'make_call', + }, + }, + { + id: 'timeout', + title: 'Timeout (seconds)', + type: 'short-input', + layout: 'half', + placeholder: '60', + condition: { + field: 'operation', + value: 'make_call', + }, + }, + { + id: 'statusCallback', + title: 'Status Callback URL', + type: 'short-input', + layout: 'full', + placeholder: 'https://example.com/status', + condition: { + field: 'operation', + value: 'make_call', + }, + }, + { + id: 'machineDetection', + title: 'Machine Detection', + type: 'dropdown', + layout: 'full', + options: [ + { label: 'Disabled', id: '' }, + { label: 'Enable', id: 'Enable' }, + { label: 'Detect Message End', id: 'DetectMessageEnd' }, + ], + condition: { + field: 'operation', + value: 'make_call', + }, + }, + { + id: 'listTo', + title: 'To Number', + type: 'short-input', + layout: 'half', + placeholder: '+14155551234', + condition: { + field: 'operation', + value: 'list_calls', + }, + }, + { + id: 'listFrom', + title: 'From Number', + type: 'short-input', + layout: 'half', + placeholder: '+14155556789', + condition: { + field: 'operation', + value: 'list_calls', + }, + }, + { + id: 'listStatus', + title: 'Status', + type: 'dropdown', + layout: 'half', + options: [ + { label: 'All', id: '' }, + { label: 'Queued', id: 'queued' }, + { label: 'Ringing', id: 'ringing' }, + { label: 'In Progress', id: 'in-progress' }, + { label: 'Completed', id: 'completed' }, + { label: 'Failed', id: 'failed' }, + { label: 'Busy', id: 'busy' }, + { label: 'No Answer', id: 'no-answer' }, + { label: 'Canceled', id: 'canceled' }, + ], + condition: { + field: 'operation', + value: 'list_calls', + }, + }, + { + id: 'listPageSize', + title: 'Page Size', + type: 'short-input', + layout: 'half', + placeholder: '50', + condition: { + field: 'operation', + value: 'list_calls', + }, + }, + { + id: 'startTimeAfter', + title: 'After (YYYY-MM-DD)', + type: 'short-input', + layout: 'half', + placeholder: '2025-01-01', + condition: { + field: 'operation', + value: 'list_calls', + }, + }, + { + id: 'startTimeBefore', + title: 'Before (YYYY-MM-DD)', + type: 'short-input', + layout: 'half', + placeholder: '2025-12-31', + condition: { + field: 'operation', + value: 'list_calls', + }, + }, + { + id: 'recordingSid', + title: 'Recording SID', + type: 'short-input', + layout: 'full', + placeholder: 'RExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + condition: { + field: 'operation', + value: 'get_recording', + }, + required: true, + }, + ], + tools: { + access: ['twilio_voice_make_call', 'twilio_voice_list_calls', 'twilio_voice_get_recording'], + config: { + tool: (params) => { + switch (params.operation) { + case 'make_call': + return 'twilio_voice_make_call' + case 'list_calls': + return 'twilio_voice_list_calls' + case 'get_recording': + return 'twilio_voice_get_recording' + default: + return 'twilio_voice_make_call' + } + }, + params: (params) => { + const { operation, timeout, record, listTo, listFrom, listStatus, listPageSize, ...rest } = + params + + const baseParams = { ...rest } + + if (operation === 'make_call' && timeout) { + baseParams.timeout = Number.parseInt(timeout, 10) + } + + if (operation === 'make_call' && record !== undefined && record !== null) { + if (typeof record === 'string') { + baseParams.record = record.toLowerCase() === 'true' || record === '1' + } else if (typeof record === 'number') { + baseParams.record = record !== 0 + } else { + baseParams.record = Boolean(record) + } + } + + if (operation === 'list_calls') { + if (listTo) baseParams.to = listTo + if (listFrom) baseParams.from = listFrom + if (listStatus) baseParams.status = listStatus + if (listPageSize) baseParams.pageSize = Number.parseInt(listPageSize, 10) + } + + return baseParams + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'Voice operation to perform' }, + accountSid: { type: 'string', description: 'Twilio Account SID' }, + authToken: { type: 'string', description: 'Twilio Auth Token' }, + to: { type: 'string', description: 'Destination phone number' }, + from: { type: 'string', description: 'Source Twilio number' }, + url: { type: 'string', description: 'TwiML URL' }, + twiml: { type: 'string', description: 'TwiML instructions' }, + record: { type: 'boolean', description: 'Record the call' }, + timeout: { type: 'string', description: 'Call timeout in seconds' }, + statusCallback: { type: 'string', description: 'Status callback URL' }, + machineDetection: { type: 'string', description: 'Answering machine detection' }, + listTo: { type: 'string', description: 'Filter calls by To number' }, + listFrom: { type: 'string', description: 'Filter calls by From number' }, + listStatus: { type: 'string', description: 'Filter calls by status' }, + listPageSize: { type: 'string', description: 'Number of calls to return per page' }, + startTimeAfter: { type: 'string', description: 'Filter calls that started after this date' }, + startTimeBefore: { type: 'string', description: 'Filter calls that started before this date' }, + recordingSid: { type: 'string', description: 'Recording SID to retrieve' }, + }, + outputs: { + success: { type: 'boolean', description: 'Operation success status' }, + callSid: { type: 'string', description: 'Call unique identifier' }, + status: { type: 'string', description: 'Call or recording status' }, + direction: { type: 'string', description: 'Call direction' }, + duration: { type: 'number', description: 'Call/recording duration in seconds' }, + price: { type: 'string', description: 'Cost of the operation' }, + priceUnit: { type: 'string', description: 'Currency of the price' }, + recordingSid: { type: 'string', description: 'Recording unique identifier' }, + channels: { type: 'number', description: 'Number of recording channels' }, + source: { type: 'string', description: 'Recording source' }, + mediaUrl: { type: 'string', description: 'URL to download recording' }, + uri: { type: 'string', description: 'Resource URI' }, + transcriptionText: { + type: 'string', + description: 'Transcribed text (only if TwiML includes )', + }, + transcriptionStatus: { + type: 'string', + description: 'Transcription status (completed, in-progress, failed)', + }, + calls: { type: 'array', description: 'Array of call objects (for list_calls operation)' }, + total: { type: 'number', description: 'Total number of calls returned' }, + page: { type: 'number', description: 'Current page number' }, + pageSize: { type: 'number', description: 'Number of calls per page' }, + error: { type: 'string', description: 'Error message if operation failed' }, + accountSid: { type: 'string', description: 'Twilio Account SID from webhook' }, + from: { type: 'string', description: "Caller's phone number (E.164 format)" }, + to: { type: 'string', description: 'Recipient phone number (your Twilio number)' }, + callStatus: { + type: 'string', + description: 'Status of the incoming call (queued, ringing, in-progress, completed, etc.)', + }, + apiVersion: { type: 'string', description: 'Twilio API version' }, + callerName: { type: 'string', description: 'Caller ID name if available' }, + forwardedFrom: { type: 'string', description: 'Phone number that forwarded this call' }, + digits: { type: 'string', description: 'DTMF digits entered by caller (from )' }, + speechResult: { type: 'string', description: 'Speech recognition result (if using )' }, + recordingUrl: { type: 'string', description: 'URL of call recording if available' }, + raw: { type: 'string', description: 'Complete raw webhook payload as JSON string' }, + }, + triggers: { + enabled: true, + available: ['twilio_voice_webhook'], + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 74832f4dd6..a63483718e 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -73,6 +73,7 @@ import { TelegramBlock } from '@/blocks/blocks/telegram' import { ThinkingBlock } from '@/blocks/blocks/thinking' import { TranslateBlock } from '@/blocks/blocks/translate' import { TwilioSMSBlock } from '@/blocks/blocks/twilio' +import { TwilioVoiceBlock } from '@/blocks/blocks/twilio_voice' import { TypeformBlock } from '@/blocks/blocks/typeform' import { VariablesBlock } from '@/blocks/blocks/variables' import { VisionBlock } from '@/blocks/blocks/vision' @@ -168,6 +169,7 @@ export const registry: Record = { thinking: ThinkingBlock, translate: TranslateBlock, twilio_sms: TwilioSMSBlock, + twilio_voice: TwilioVoiceBlock, typeform: TypeformBlock, variables: VariablesBlock, vision: VisionBlock, diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index de40a5d1a7..79242d67a7 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -17,6 +17,8 @@ vi.mock('@/lib/environment', () => ({ isDev: vi.fn().mockReturnValue(true), isTest: vi.fn().mockReturnValue(false), getCostMultiplier: vi.fn().mockReturnValue(1), + isEmailVerificationEnabled: false, + isBillingEnabled: false, })) vi.mock('@/providers/utils', () => ({ diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 95ebb9643b..dad858c99e 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -8,12 +8,13 @@ import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { env, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { LoggingSession } from '@/lib/logs/execution/logging-session' +import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' import { handleSlackChallenge, handleWhatsAppVerification, validateMicrosoftTeamsSignature, verifyProviderWebhook, -} from '@/lib/webhooks/utils' +} from '@/lib/webhooks/utils.server' import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils' import { executeWebhookJob } from '@/background/webhook-execution' import { RateLimiter } from '@/services/queue' @@ -28,6 +29,19 @@ export interface WebhookProcessorOptions { executionTarget?: 'deployed' | 'live' } +function getExternalUrl(request: NextRequest): string { + const proto = request.headers.get('x-forwarded-proto') || 'https' + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') + + if (host) { + const url = new URL(request.url) + const reconstructed = `${proto}://${host}${url.pathname}${url.search}` + return reconstructed + } + + return request.url +} + async function resolveWorkflowActorUserId(foundWorkflow: { workspaceId?: string | null userId?: string | null @@ -70,13 +84,13 @@ export async function parseWebhookBody( const formData = new URLSearchParams(rawBody) const payloadString = formData.get('payload') - if (!payloadString) { - logger.warn(`[${requestId}] No payload field found in form-encoded data`) - return new NextResponse('Missing payload field', { status: 400 }) + if (payloadString) { + body = JSON.parse(payloadString) + logger.debug(`[${requestId}] Parsed form-encoded GitHub webhook payload`) + } else { + body = Object.fromEntries(formData.entries()) + logger.debug(`[${requestId}] Parsed form-encoded webhook data (direct fields)`) } - - body = JSON.parse(payloadString) - logger.debug(`[${requestId}] Parsed form-encoded GitHub webhook payload`) } else { body = JSON.parse(rawBody) logger.debug(`[${requestId}] Parsed JSON webhook payload`) @@ -166,15 +180,76 @@ export async function findWebhookAndWorkflow( return null } +/** + * Resolve {{VARIABLE}} references in a string value + * @param value - String that may contain {{VARIABLE}} references + * @param envVars - Already decrypted environment variables + * @returns String with all {{VARIABLE}} references replaced + */ +function resolveEnvVars(value: string, envVars: Record): string { + const envMatches = value.match(/\{\{([^}]+)\}\}/g) + if (!envMatches) return value + + let resolvedValue = value + for (const match of envMatches) { + const envKey = match.slice(2, -2).trim() + const envValue = envVars[envKey] + if (envValue !== undefined) { + resolvedValue = resolvedValue.replaceAll(match, envValue) + } + } + return resolvedValue +} + +/** + * Resolve environment variables in webhook providerConfig + * @param config - Raw providerConfig from database (may contain {{VARIABLE}} refs) + * @param envVars - Already decrypted environment variables + * @returns New object with resolved values (original config is unchanged) + */ +function resolveProviderConfigEnvVars( + config: Record, + envVars: Record +): Record { + const resolved: Record = {} + for (const [key, value] of Object.entries(config)) { + if (typeof value === 'string') { + resolved[key] = resolveEnvVars(value, envVars) + } else { + resolved[key] = value + } + } + return resolved +} + +/** + * Verify webhook provider authentication and signatures + * @returns NextResponse with 401 if auth fails, null if auth passes + */ export async function verifyProviderAuth( foundWebhook: any, + foundWorkflow: any, request: NextRequest, rawBody: string, requestId: string ): Promise { - if (foundWebhook.provider === 'microsoftteams') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} + // Step 1: Fetch and decrypt environment variables for signature verification + let decryptedEnvVars: Record = {} + try { + const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils') + decryptedEnvVars = await getEffectiveDecryptedEnv( + foundWorkflow.userId, + foundWorkflow.workspaceId + ) + } catch (error) { + logger.error(`[${requestId}] Failed to fetch environment variables`, { error }) + } + // Step 2: Resolve {{VARIABLE}} references in providerConfig + const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} + const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars) + + if (foundWebhook.provider === 'microsoftteams') { if (providerConfig.hmacSecret) { const authHeader = request.headers.get('authorization') @@ -208,7 +283,6 @@ export async function verifyProviderAuth( // Handle Google Forms shared-secret authentication (Apps Script forwarder) if (foundWebhook.provider === 'google_forms') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} const expectedToken = providerConfig.token as string | undefined const secretHeaderName = providerConfig.secretHeaderName as string | undefined @@ -237,10 +311,53 @@ export async function verifyProviderAuth( } } - // Generic webhook authentication - if (foundWebhook.provider === 'generic') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} + // Twilio Voice webhook signature verification + if (foundWebhook.provider === 'twilio_voice') { + const authToken = providerConfig.authToken as string | undefined + + if (authToken) { + const signature = request.headers.get('x-twilio-signature') + + if (!signature) { + logger.warn(`[${requestId}] Twilio Voice webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Twilio signature', { status: 401 }) + } + + let params: Record = {} + try { + if (typeof rawBody === 'string') { + const urlParams = new URLSearchParams(rawBody) + params = Object.fromEntries(urlParams.entries()) + } + } catch (error) { + logger.error( + `[${requestId}] Error parsing Twilio webhook body for signature validation:`, + error + ) + return new NextResponse('Bad Request - Invalid body format', { status: 400 }) + } + + const fullUrl = getExternalUrl(request) + + const { validateTwilioSignature } = await import('@/lib/webhooks/utils.server') + + const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Twilio Voice signature verification failed`, { + url: fullUrl, + signatureLength: signature.length, + paramsCount: Object.keys(params).length, + authTokenLength: authToken.length, + }) + return new NextResponse('Unauthorized - Invalid Twilio signature', { status: 401 }) + } + + logger.debug(`[${requestId}] Twilio Voice signature verified successfully`) + } + } + if (foundWebhook.provider === 'generic') { if (providerConfig.requireAuth) { const configToken = providerConfig.token const secretHeaderName = providerConfig.secretHeaderName @@ -249,13 +366,11 @@ export async function verifyProviderAuth( let isTokenValid = false if (secretHeaderName) { - // Check custom header (headers are case-insensitive) const headerValue = request.headers.get(secretHeaderName.toLowerCase()) if (headerValue === configToken) { isTokenValid = true } } else { - // Check Authorization: Bearer (case-insensitive) const authHeader = request.headers.get('authorization') if (authHeader?.toLowerCase().startsWith('bearer ')) { const token = authHeader.substring(7) @@ -520,6 +635,37 @@ export async function queueWebhookExecution( }) } + // Twilio Voice requires TwiML XML response + if (foundWebhook.provider === 'twilio_voice') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() + + // If user provided custom TwiML, convert square brackets to angle brackets and return + if (twimlResponse && twimlResponse.length > 0) { + const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) + return new NextResponse(convertedTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + } + + // Default TwiML if none provided + const defaultTwiml = ` + + Your call is being processed. + +` + + return new NextResponse(defaultTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + } + return NextResponse.json({ message: 'Webhook processed' }) } catch (error: any) { logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error) @@ -534,6 +680,21 @@ export async function queueWebhookExecution( ) } + if (foundWebhook.provider === 'twilio_voice') { + const errorTwiml = ` + + We're sorry, but an error occurred processing your call. Please try again later. + +` + + return new NextResponse(errorTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml', + }, + }) + } + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts new file mode 100644 index 0000000000..e774068143 --- /dev/null +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -0,0 +1,1967 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { createLogger } from '@/lib/logs/console/logger' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookUtils') + +/** + * Handle WhatsApp verification requests + */ +export async function handleWhatsAppVerification( + requestId: string, + path: string, + mode: string | null, + token: string | null, + challenge: string | null +): Promise { + if (mode && token && challenge) { + // This is a WhatsApp verification request + logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) + + if (mode !== 'subscribe') { + logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) + return new NextResponse('Invalid mode', { status: 400 }) + } + + // Find all active WhatsApp webhooks + const webhooks = await db + .select() + .from(webhook) + .where(and(eq(webhook.provider, 'whatsapp'), eq(webhook.isActive, true))) + + // Check if any webhook has a matching verification token + for (const wh of webhooks) { + const providerConfig = (wh.providerConfig as Record) || {} + const verificationToken = providerConfig.verificationToken + + if (!verificationToken) { + logger.debug(`[${requestId}] Webhook ${wh.id} has no verification token, skipping`) + continue + } + + if (token === verificationToken) { + logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) + // Return ONLY the challenge as plain text (exactly as WhatsApp expects) + return new NextResponse(challenge, { + status: 200, + headers: { + 'Content-Type': 'text/plain', + }, + }) + } + } + + logger.warn(`[${requestId}] No matching WhatsApp verification token found`) + return new NextResponse('Verification failed', { status: 403 }) + } + + return null +} + +/** + * Handle Slack verification challenges + */ +export function handleSlackChallenge(body: any): NextResponse | null { + if (body.type === 'url_verification' && body.challenge) { + return NextResponse.json({ challenge: body.challenge }) + } + + return null +} + +/** + * Format Microsoft Teams Graph change notification + */ +async function formatTeamsGraphNotification( + body: any, + foundWebhook: any, + foundWorkflow: any, + request: NextRequest +): Promise { + const notification = body.value[0] + const changeType = notification.changeType || 'created' + const resource = notification.resource || '' + const subscriptionId = notification.subscriptionId || '' + + // Extract chatId and messageId from resource path + let chatId: string | null = null + let messageId: string | null = null + + const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) + if (fullMatch) { + chatId = fullMatch[1] + messageId = fullMatch[2] + } + + if (!chatId || !messageId) { + const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (quotedMatch) { + chatId = quotedMatch[1] + messageId = quotedMatch[2] + } + } + + if (!chatId || !messageId) { + const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) + const rdId = body?.value?.[0]?.resourceData?.id + if (collectionMatch && rdId) { + chatId = collectionMatch[1] + messageId = rdId + } + } + + if ((!chatId || !messageId) && body?.value?.[0]?.resourceData?.['@odata.id']) { + const odataId = String(body.value[0].resourceData['@odata.id']) + const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (odataMatch) { + chatId = odataMatch[1] + messageId = odataMatch[2] + } + } + + if (!chatId || !messageId) { + logger.warn('Could not resolve chatId/messageId from Teams notification', { + resource, + hasResourceDataId: Boolean(body?.value?.[0]?.resourceData?.id), + valueLength: Array.isArray(body?.value) ? body.value.length : 0, + keys: Object.keys(body || {}), + }) + return { + input: 'Teams notification received', + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + const resolvedChatId = chatId as string + const resolvedMessageId = messageId as string + const providerConfig = (foundWebhook?.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId + const includeAttachments = providerConfig.includeAttachments !== false + + let message: any = null + const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = + [] + let accessToken: string | null = null + + // Teams chat subscriptions require credentials + if (!credentialId) { + logger.error('Missing credentialId for Teams chat subscription', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + webhookId: foundWebhook?.id, + blockId: foundWebhook?.blockId, + providerConfig, + }) + } else { + try { + // Get userId from credential + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) + // Continue without message data + } else { + const effectiveUserId = rows[0].userId + accessToken = await refreshAccessTokenIfNeeded( + credentialId, + effectiveUserId, + 'teams-graph-notification' + ) + } + + if (accessToken) { + const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` + const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) + if (res.ok) { + message = await res.json() + + if (includeAttachments && message?.attachments?.length > 0) { + const attachments = Array.isArray(message?.attachments) ? message.attachments : [] + for (const att of attachments) { + try { + const contentUrl = + typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined + const contentTypeHint = + typeof att?.contentType === 'string' ? (att.contentType as string) : undefined + let attachmentName = (att?.name as string) || 'teams-attachment' + + if (!contentUrl) continue + + let buffer: Buffer | null = null + let mimeType = 'application/octet-stream' + + if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { + try { + const directRes = await fetch(contentUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (directRes.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + const encodedUrl = Buffer.from(contentUrl) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` + const graphRes = await fetch(graphUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (graphRes.ok) { + const arrayBuffer = await graphRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + graphRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } + } catch { + continue + } + } else if ( + contentUrl.includes('1drv.ms') || + contentUrl.includes('onedrive.live.com') || + contentUrl.includes('onedrive.com') || + contentUrl.includes('my.microsoftpersonalcontent.com') + ) { + try { + let shareToken: string | null = null + + if (contentUrl.includes('1drv.ms')) { + const urlParts = contentUrl.split('/').pop() + if (urlParts) shareToken = urlParts + } else if (contentUrl.includes('resid=')) { + const urlParams = new URL(contentUrl).searchParams + const resId = urlParams.get('resid') + if (resId) shareToken = resId + } + + if (!shareToken) { + const base64Url = Buffer.from(contentUrl, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } else if (!shareToken.startsWith('u!')) { + const base64Url = Buffer.from(shareToken, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } + + const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` + const metadataRes = await fetch(metadataUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!metadataRes.ok) { + const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` + const directRes = await fetch(directUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (directRes.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } else { + const metadata = await metadataRes.json() + const downloadUrl = metadata['@microsoft.graph.downloadUrl'] + + if (downloadUrl) { + const downloadRes = await fetch(downloadUrl) + + if (downloadRes.ok) { + const arrayBuffer = await downloadRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + downloadRes.headers.get('content-type') || + metadata.file?.mimeType || + contentTypeHint || + 'application/octet-stream' + + if (metadata.name && metadata.name !== attachmentName) { + attachmentName = metadata.name + } + } else { + continue + } + } else { + continue + } + } + } catch { + continue + } + } else { + try { + const ares = await fetch(contentUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (ares.ok) { + const arrayBuffer = await ares.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + ares.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } + } catch { + continue + } + } + + if (!buffer) continue + + const size = buffer.length + + // Store raw attachment (will be uploaded to execution storage later) + rawAttachments.push({ + name: attachmentName, + data: buffer, + contentType: mimeType, + size, + }) + } catch {} + } + } + } + } + } catch (error) { + logger.error('Failed to fetch Teams message', { + error, + chatId: resolvedChatId, + messageId: resolvedMessageId, + }) + } + } + + // If no message was fetched, return minimal data + if (!message) { + logger.warn('No message data available for Teams notification', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + hasCredential: !!credentialId, + }) + return { + input: '', + message_id: messageId, + chat_id: chatId, + from_name: 'Unknown', + text: '', + created_at: notification.resourceData?.createdDateTime || '', + change_type: changeType, + subscription_id: subscriptionId, + attachments: [], + microsoftteams: { + message: { id: messageId, text: '', timestamp: '', chatId, raw: null }, + from: { id: '', name: 'Unknown', aadObjectId: '' }, + notification: { changeType, subscriptionId, resource }, + }, + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + // Extract data from message - we know it exists now + // body.content is the HTML/text content, summary is a plain text preview (max 280 chars) + const messageText = message.body?.content || '' + const from = message.from?.user || {} + const createdAt = message.createdDateTime || '' + + return { + input: messageText, + message_id: messageId, + chat_id: chatId, + from_name: from.displayName || 'Unknown', + text: messageText, + created_at: createdAt, + change_type: changeType, + subscription_id: subscriptionId, + attachments: rawAttachments, + microsoftteams: { + message: { + id: messageId, + text: messageText, + timestamp: createdAt, + chatId, + raw: message, + }, + from: { + id: from.id, + name: from.displayName, + aadObjectId: from.aadObjectId, + }, + notification: { + changeType, + subscriptionId, + resource, + }, + }, + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook?.path || '', + providerConfig: foundWebhook?.providerConfig || {}, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } +} + +export async function validateTwilioSignature( + authToken: string, + signature: string, + url: string, + params: Record +): Promise { + try { + if (!authToken || !signature || !url) { + logger.warn('Twilio signature validation missing required fields', { + hasAuthToken: !!authToken, + hasSignature: !!signature, + hasUrl: !!url, + }) + return false + } + + const sortedKeys = Object.keys(params).sort() + let data = url + for (const key of sortedKeys) { + data += key + params[key] + } + + logger.debug('Twilio signature validation string built', { + url, + sortedKeys, + dataLength: data.length, + }) + + const encoder = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(authToken), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ) + + const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) + + const signatureArray = Array.from(new Uint8Array(signatureBytes)) + const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) + + logger.debug('Twilio signature comparison', { + computedSignature: `${signatureBase64.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: signatureBase64.length, + providedLength: signature.length, + match: signatureBase64 === signature, + }) + + if (signatureBase64.length !== signature.length) { + return false + } + + let result = 0 + for (let i = 0; i < signatureBase64.length; i++) { + result |= signatureBase64.charCodeAt(i) ^ signature.charCodeAt(i) + } + + return result === 0 + } catch (error) { + logger.error('Error validating Twilio signature:', error) + return false + } +} + +/** + * Format webhook input based on provider + */ +export async function formatWebhookInput( + foundWebhook: any, + foundWorkflow: any, + body: any, + request: NextRequest +): Promise { + if (foundWebhook.provider === 'whatsapp') { + const data = body?.entry?.[0]?.changes?.[0]?.value + const messages = data?.messages || [] + + if (messages.length > 0) { + const message = messages[0] + const phoneNumberId = data.metadata?.phone_number_id + const from = message.from + const messageId = message.id + const timestamp = message.timestamp + const text = message.text?.body + + return { + whatsapp: { + data: { + messageId, + from, + phoneNumberId, + text, + timestamp, + raw: message, + }, + }, + webhook: { + data: { + provider: 'whatsapp', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + return null + } + + if (foundWebhook.provider === 'telegram') { + const message = + body?.message || body?.edited_message || body?.channel_post || body?.edited_channel_post + + if (message) { + let input = '' + + if (message.text) { + input = message.text + } else if (message.caption) { + input = message.caption + } else if (message.photo) { + input = 'Photo message' + } else if (message.document) { + input = `Document: ${message.document.file_name || 'file'}` + } else if (message.audio) { + input = `Audio: ${message.audio.title || 'audio file'}` + } else if (message.video) { + input = 'Video message' + } else if (message.voice) { + input = 'Voice message' + } else if (message.sticker) { + input = `Sticker: ${message.sticker.emoji || '🎭'}` + } else if (message.location) { + input = 'Location shared' + } else if (message.contact) { + input = `Contact: ${message.contact.first_name || 'contact'}` + } else if (message.poll) { + input = `Poll: ${message.poll.question}` + } else { + input = 'Message received' + } + + const messageObj = { + id: message.message_id, + text: message.text, + caption: message.caption, + date: message.date, + messageType: message.photo + ? 'photo' + : message.document + ? 'document' + : message.audio + ? 'audio' + : message.video + ? 'video' + : message.voice + ? 'voice' + : message.sticker + ? 'sticker' + : message.location + ? 'location' + : message.contact + ? 'contact' + : message.poll + ? 'poll' + : 'text', + raw: message, + } + + const senderObj = message.from + ? { + id: message.from.id, + firstName: message.from.first_name, + lastName: message.from.last_name, + username: message.from.username, + languageCode: message.from.language_code, + isBot: message.from.is_bot, + } + : null + + const chatObj = message.chat + ? { + id: message.chat.id, + type: message.chat.type, + title: message.chat.title, + username: message.chat.username, + firstName: message.chat.first_name, + lastName: message.chat.last_name, + } + : null + + return { + input, + + // Top-level properties for backward compatibility with syntax + message: messageObj, + sender: senderObj, + chat: chatObj, + updateId: body.update_id, + updateType: body.message + ? 'message' + : body.edited_message + ? 'edited_message' + : body.channel_post + ? 'channel_post' + : body.edited_channel_post + ? 'edited_channel_post' + : 'unknown', + + // Keep the nested structure for the new telegram.message.text syntax + telegram: { + message: messageObj, + sender: senderObj, + chat: chatObj, + updateId: body.update_id, + updateType: body.message + ? 'message' + : body.edited_message + ? 'edited_message' + : body.channel_post + ? 'channel_post' + : body.edited_channel_post + ? 'edited_channel_post' + : 'unknown', + }, + webhook: { + data: { + provider: 'telegram', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + // Fallback for unknown Telegram update types + logger.warn('Unknown Telegram update type', { + updateId: body.update_id, + bodyKeys: Object.keys(body || {}), + }) + + return { + input: 'Telegram update received', + telegram: { + updateId: body.update_id, + updateType: 'unknown', + raw: body, + }, + webhook: { + data: { + provider: 'telegram', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + if (foundWebhook.provider === 'twilio_voice') { + return { + // Root-level properties matching trigger outputs for easy access + callSid: body.CallSid, + accountSid: body.AccountSid, + from: body.From, + to: body.To, + callStatus: body.CallStatus, + direction: body.Direction, + apiVersion: body.ApiVersion, + callerName: body.CallerName, + forwardedFrom: body.ForwardedFrom, + digits: body.Digits, + speechResult: body.SpeechResult, + recordingUrl: body.RecordingUrl, + recordingSid: body.RecordingSid, + + // Additional fields from Twilio payload + called: body.Called, + caller: body.Caller, + toCity: body.ToCity, + toState: body.ToState, + toZip: body.ToZip, + toCountry: body.ToCountry, + fromCity: body.FromCity, + fromState: body.FromState, + fromZip: body.FromZip, + fromCountry: body.FromCountry, + calledCity: body.CalledCity, + calledState: body.CalledState, + calledZip: body.CalledZip, + calledCountry: body.CalledCountry, + callerCity: body.CallerCity, + callerState: body.CallerState, + callerZip: body.CallerZip, + callerCountry: body.CallerCountry, + callToken: body.CallToken, + + webhook: { + data: { + provider: 'twilio_voice', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + if (foundWebhook.provider === 'gmail') { + if (body && typeof body === 'object' && 'email' in body) { + return body // { email: {...}, timestamp: ... } + } + return body + } + + if (foundWebhook.provider === 'outlook') { + if (body && typeof body === 'object' && 'email' in body) { + return body // { email: {...}, timestamp: ... } + } + return body + } + + if (foundWebhook.provider === 'microsoftteams') { + // Check if this is a Microsoft Graph change notification + if (body?.value && Array.isArray(body.value) && body.value.length > 0) { + return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request) + } + + // Microsoft Teams outgoing webhook - Teams sending data to us + const messageText = body?.text || '' + const messageId = body?.id || '' + const timestamp = body?.timestamp || body?.localTimestamp || '' + const from = body?.from || {} + const conversation = body?.conversation || {} + + // Construct the message object + const messageObj = { + raw: { + attachments: body?.attachments || [], + channelData: body?.channelData || {}, + conversation: body?.conversation || {}, + text: messageText, + messageType: body?.type || 'message', + channelId: body?.channelId || '', + timestamp, + }, + } + + // Construct the from object + const fromObj = { + id: from.id || '', + name: from.name || '', + aadObjectId: from.aadObjectId || '', + } + + // Construct the conversation object + const conversationObj = { + id: conversation.id || '', + name: conversation.name || '', + isGroup: conversation.isGroup || false, + tenantId: conversation.tenantId || '', + aadObjectId: conversation.aadObjectId || '', + conversationType: conversation.conversationType || '', + } + + // Construct the activity object + const activityObj = body || {} + + return { + input: messageText, // Primary workflow input - the message text + + // Top-level properties for direct access with syntax + from: fromObj, + message: messageObj, + activity: activityObj, + conversation: conversationObj, + + webhook: { + data: { + provider: 'microsoftteams', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + if (foundWebhook.provider === 'slack') { + // Slack input formatting logic - check for valid event + const event = body?.event + + if (event && body?.type === 'event_callback') { + // Extract event text with fallbacks for different event types + let input = '' + + if (event.text) { + input = event.text + } else if (event.type === 'app_mention') { + input = 'App mention received' + } else { + input = 'Slack event received' + } + + // Create the event object for easier access + const eventObj = { + event_type: event.type || '', + channel: event.channel || '', + channel_name: '', // Could be resolved via additional API calls if needed + user: event.user || '', + user_name: '', // Could be resolved via additional API calls if needed + text: event.text || '', + timestamp: event.ts || event.event_ts || '', + team_id: body.team_id || event.team || '', + event_id: body.event_id || '', + } + + return { + input, // Primary workflow input - the event content + + // // // Top-level properties for backward compatibility with syntax + event: eventObj, + + // Keep the nested structure for the new slack.event.text syntax + slack: { + event: eventObj, + }, + webhook: { + data: { + provider: 'slack', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + // Fallback for unknown Slack event types + logger.warn('Unknown Slack event type', { + type: body?.type, + hasEvent: !!body?.event, + bodyKeys: Object.keys(body || {}), + }) + + return { + input: 'Slack webhook received', + slack: { + event: { + event_type: body?.event?.type || body?.type || 'unknown', + channel: body?.event?.channel || '', + user: body?.event?.user || '', + text: body?.event?.text || '', + timestamp: body?.event?.ts || '', + team_id: body?.team_id || '', + event_id: body?.event_id || '', + }, + }, + webhook: { + data: { + provider: 'slack', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + if (foundWebhook.provider === 'webflow') { + const triggerType = body?.triggerType || 'unknown' + const siteId = body?.siteId || '' + const workspaceId = body?.workspaceId || '' + const collectionId = body?.collectionId || '' + const payload = body?.payload || {} + const formId = body?.formId || '' + const formName = body?.name || '' + const formSubmissionId = body?.id || '' + const submittedAt = body?.submittedAt || '' + const formData = body?.data || {} + const schema = body?.schema || {} + + return { + siteId, + workspaceId, + collectionId, + payload, + triggerType, + + formId, + name: formName, + id: formSubmissionId, + submittedAt, + data: formData, + schema, + formElementId: body?.formElementId || '', + + webflow: { + siteId, + workspaceId, + collectionId, + payload, + triggerType, + raw: body, + }, + + webhook: { + data: { + provider: 'webflow', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + if (foundWebhook.provider === 'generic') { + return body + } + + if (foundWebhook.provider === 'google_forms') { + const providerConfig = (foundWebhook.providerConfig as Record) || {} + + // Normalize answers: if value is an array with single element, collapse to scalar; keep multi-select arrays + const normalizeAnswers = (src: unknown): Record => { + if (!src || typeof src !== 'object') return {} + const out: Record = {} + for (const [k, v] of Object.entries(src as Record)) { + if (Array.isArray(v)) { + out[k] = v.length === 1 ? v[0] : v + } else { + out[k] = v as unknown + } + } + return out + } + + const responseId = body?.responseId || body?.id || '' + const createTime = body?.createTime || body?.timestamp || new Date().toISOString() + const lastSubmittedTime = body?.lastSubmittedTime || createTime + const formId = body?.formId || providerConfig.formId || '' + const includeRaw = providerConfig.includeRawPayload !== false + + const normalizedAnswers = normalizeAnswers(body?.answers) + + const summaryCount = Object.keys(normalizedAnswers).length + const input = `Google Form response${responseId ? ` ${responseId}` : ''} (${summaryCount} answers)` + + return { + input, + responseId, + createTime, + lastSubmittedTime, + formId, + answers: normalizedAnswers, + ...(includeRaw ? { raw: body?.raw ?? body } : {}), + google_forms: { + responseId, + createTime, + lastSubmittedTime, + formId, + answers: normalizedAnswers, + ...(includeRaw ? { raw: body?.raw ?? body } : {}), + }, + webhook: { + data: { + provider: 'google_forms', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: includeRaw ? body : undefined, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + if (foundWebhook.provider === 'github') { + // GitHub webhook input formatting logic + const eventType = request.headers.get('x-github-event') || 'unknown' + const delivery = request.headers.get('x-github-delivery') || '' + + // Extract common GitHub properties + const repository = body?.repository || {} + const sender = body?.sender || {} + const action = body?.action || '' + + // Build GitHub-specific variables based on the trigger config outputs + const githubData = { + // Event metadata + event_type: eventType, + action: action, + delivery_id: delivery, + + // Repository information (avoid 'repository' to prevent conflict with the object) + repository_full_name: repository.full_name || '', + repository_name: repository.name || '', + repository_owner: repository.owner?.login || '', + repository_id: repository.id || '', + repository_url: repository.html_url || '', + + // Sender information (avoid 'sender' to prevent conflict with the object) + sender_login: sender.login || '', + sender_id: sender.id || '', + sender_type: sender.type || '', + sender_url: sender.html_url || '', + + // Event-specific data + ...(body?.ref && { + ref: body.ref, + branch: body.ref?.replace('refs/heads/', '') || '', + }), + ...(body?.before && { before: body.before }), + ...(body?.after && { after: body.after }), + ...(body?.commits && { + commits: JSON.stringify(body.commits), + commit_count: body.commits.length || 0, + }), + ...(body?.head_commit && { + commit_message: body.head_commit.message || '', + commit_author: body.head_commit.author?.name || '', + commit_sha: body.head_commit.id || '', + commit_url: body.head_commit.url || '', + }), + ...(body?.pull_request && { + pull_request: JSON.stringify(body.pull_request), + pr_number: body.pull_request.number || '', + pr_title: body.pull_request.title || '', + pr_state: body.pull_request.state || '', + pr_url: body.pull_request.html_url || '', + }), + ...(body?.issue && { + issue: JSON.stringify(body.issue), + issue_number: body.issue.number || '', + issue_title: body.issue.title || '', + issue_state: body.issue.state || '', + issue_url: body.issue.html_url || '', + }), + ...(body?.comment && { + comment: JSON.stringify(body.comment), + comment_body: body.comment.body || '', + comment_url: body.comment.html_url || '', + }), + } + + // Set input based on event type for workflow processing + let input = '' + switch (eventType) { + case 'push': + input = `Push to ${githubData.branch || githubData.ref}: ${githubData.commit_message || 'No commit message'}` + break + case 'pull_request': + input = `${action} pull request: ${githubData.pr_title || 'No title'}` + break + case 'issues': + input = `${action} issue: ${githubData.issue_title || 'No title'}` + break + case 'issue_comment': + case 'pull_request_review_comment': + input = `Comment ${action}: ${githubData.comment_body?.slice(0, 100) || 'No comment body'}${(githubData.comment_body?.length || 0) > 100 ? '...' : ''}` + break + default: + input = `GitHub ${eventType} event${action ? ` (${action})` : ''}` + } + + return { + // Expose raw GitHub payload at the root + ...body, + // Include webhook metadata alongside + webhook: { + data: { + provider: 'github', + path: foundWebhook.path, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } + } + + // Generic format for other providers + return { + webhook: { + data: { + path: foundWebhook.path, + provider: foundWebhook.provider, + providerConfig: foundWebhook.providerConfig, + payload: body, + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + }, + }, + workflowId: foundWorkflow.id, + } +} + +/** + * Validates a Microsoft Teams outgoing webhook request signature using HMAC SHA-256 + * @param hmacSecret - Microsoft Teams HMAC secret (base64 encoded) + * @param signature - Authorization header value (should start with 'HMAC ') + * @param body - Raw request body string + * @returns Whether the signature is valid + */ +export function validateMicrosoftTeamsSignature( + hmacSecret: string, + signature: string, + body: string +): boolean { + try { + // Basic validation first + if (!hmacSecret || !signature || !body) { + return false + } + + // Check if signature has correct format + if (!signature.startsWith('HMAC ')) { + return false + } + + const providedSignature = signature.substring(5) // Remove 'HMAC ' prefix + + // Compute HMAC SHA256 signature using Node.js crypto + const crypto = require('crypto') + const secretBytes = Buffer.from(hmacSecret, 'base64') + const bodyBytes = Buffer.from(body, 'utf8') + const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') + + // Constant-time comparison to prevent timing attacks + if (computedHash.length !== providedSignature.length) { + return false + } + + let result = 0 + for (let i = 0; i < computedHash.length; i++) { + result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) + } + + return result === 0 + } catch (error) { + logger.error('Error validating Microsoft Teams signature:', error) + return false + } +} + +/** + * Process webhook provider-specific verification + */ +export function verifyProviderWebhook( + foundWebhook: any, + request: NextRequest, + requestId: string +): NextResponse | null { + const authHeader = request.headers.get('authorization') + const providerConfig = (foundWebhook.providerConfig as Record) || {} + switch (foundWebhook.provider) { + case 'github': + break + case 'stripe': + break + case 'gmail': + if (providerConfig.secret) { + const secretHeader = request.headers.get('X-Webhook-Secret') + if (!secretHeader || secretHeader.length !== providerConfig.secret.length) { + logger.warn(`[${requestId}] Invalid Gmail webhook secret`) + return new NextResponse('Unauthorized', { status: 401 }) + } + let result = 0 + for (let i = 0; i < secretHeader.length; i++) { + result |= secretHeader.charCodeAt(i) ^ providerConfig.secret.charCodeAt(i) + } + if (result !== 0) { + logger.warn(`[${requestId}] Invalid Gmail webhook secret`) + return new NextResponse('Unauthorized', { status: 401 }) + } + } + break + case 'telegram': { + // Check User-Agent to ensure it's not blocked by middleware + const userAgent = request.headers.get('user-agent') || '' + logger.debug(`[${requestId}] Telegram webhook request received with User-Agent: ${userAgent}`) + + if (!userAgent) { + logger.warn( + `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` + ) + } + + // Telegram uses IP addresses in specific ranges + const clientIp = + request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + request.headers.get('x-real-ip') || + 'unknown' + + logger.debug(`[${requestId}] Telegram webhook request from IP: ${clientIp}`) + + break + } + case 'microsoftteams': + break + case 'generic': + if (providerConfig.requireAuth) { + let isAuthenticated = false + if (providerConfig.token) { + const bearerMatch = authHeader?.match(/^bearer\s+(.+)$/i) + const providedToken = bearerMatch ? bearerMatch[1] : null + if (providedToken === providerConfig.token) { + isAuthenticated = true + } + if (!isAuthenticated && providerConfig.secretHeaderName) { + const customHeaderValue = request.headers.get(providerConfig.secretHeaderName) + if (customHeaderValue === providerConfig.token) { + isAuthenticated = true + } + } + if (!isAuthenticated) { + logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) + return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) + } + } + } + if ( + providerConfig.allowedIps && + Array.isArray(providerConfig.allowedIps) && + providerConfig.allowedIps.length > 0 + ) { + const clientIp = + request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + request.headers.get('x-real-ip') || + 'unknown' + + if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) { + logger.warn( + `[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}` + ) + return new NextResponse('Forbidden - IP not allowed', { + status: 403, + }) + } + } + break + default: + if (providerConfig.token) { + const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null + if (!providedToken || providedToken !== providerConfig.token) { + logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) + return new NextResponse('Unauthorized', { status: 401 }) + } + } + } + + return null +} + +/** + * Process Airtable payloads + */ +export async function fetchAndProcessAirtablePayloads( + webhookData: any, + workflowData: any, + requestId: string // Original request ID from the ping, used for the final execution log +) { + // Logging handles all error logging + let currentCursor: number | null = null + let mightHaveMore = true + let payloadsFetched = 0 + let apiCallCount = 0 + // Use a Map to consolidate changes per record ID + const consolidatedChangesMap = new Map() + // Capture raw payloads from Airtable for exposure to workflows + const allPayloads = [] + const localProviderConfig = { + ...((webhookData.providerConfig as Record) || {}), + } + + try { + // --- Essential IDs & Config from localProviderConfig --- + const baseId = localProviderConfig.baseId + const airtableWebhookId = localProviderConfig.externalId + + if (!baseId || !airtableWebhookId) { + logger.error( + `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` + ) + return + } + + const credentialId: string | undefined = localProviderConfig.credentialId + if (!credentialId) { + logger.error( + `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` + ) + return + } + + let ownerUserId: string | null = null + try { + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + ownerUserId = rows.length ? rows[0].userId : null + } catch (_e) { + ownerUserId = null + } + + if (!ownerUserId) { + logger.error( + `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` + ) + return + } + + const storedCursor = localProviderConfig.externalWebhookCursor + + if (storedCursor === undefined || storedCursor === null) { + logger.info( + `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` + ) + localProviderConfig.externalWebhookCursor = null + + try { + await db + .update(webhook) + .set({ + providerConfig: { + ...localProviderConfig, + externalWebhookCursor: null, + }, + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id)) + + localProviderConfig.externalWebhookCursor = null + logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) + } catch (initError: any) { + logger.error(`[${requestId}] Failed to initialize cursor in DB`, { + webhookId: webhookData.id, + error: initError.message, + stack: initError.stack, + }) + } + } + + if (storedCursor && typeof storedCursor === 'number') { + currentCursor = storedCursor + logger.debug( + `[${requestId}] Using stored cursor: ${currentCursor} for webhook ${webhookData.id}` + ) + } else { + currentCursor = null + logger.debug( + `[${requestId}] No valid stored cursor for webhook ${webhookData.id}, starting from beginning` + ) + } + + let accessToken: string | null = null + try { + accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` + ) + throw new Error('Airtable access token not found.') + } + + logger.info(`[${requestId}] Successfully obtained Airtable access token`) + } catch (tokenError: any) { + logger.error( + `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, + { + error: tokenError.message, + stack: tokenError.stack, + credentialId, + } + ) + return + } + + const airtableApiBase = 'https://api.airtable.com/v0' + + // --- Polling Loop --- + while (mightHaveMore) { + apiCallCount++ + // Safety break + if (apiCallCount > 10) { + logger.warn(`[${requestId}] Reached maximum polling limit (10 calls)`, { + webhookId: webhookData.id, + consolidatedCount: consolidatedChangesMap.size, + }) + mightHaveMore = false + break + } + + const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` + const queryParams = new URLSearchParams() + if (currentCursor !== null) { + queryParams.set('cursor', currentCursor.toString()) + } + const fullUrl = `${apiUrl}?${queryParams.toString()}` + + logger.debug(`[${requestId}] Fetching Airtable payloads (call ${apiCallCount})`, { + url: fullUrl, + webhookId: webhookData.id, + }) + + try { + const fetchStartTime = Date.now() + const response = await fetch(fullUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + // DEBUG: Log API response time + logger.debug(`[${requestId}] TRACE: Airtable API response received`, { + status: response.status, + duration: `${Date.now() - fetchStartTime}ms`, + hasBody: true, + apiCall: apiCallCount, + }) + + const responseBody = await response.json() + + if (!response.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || + responseBody.error || + `Airtable API error Status ${response.status}` + logger.error( + `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, + { + webhookId: webhookData.id, + status: response.status, + error: errorMessage, + } + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + + const receivedPayloads = responseBody.payloads || [] + logger.debug( + `[${requestId}] Received ${receivedPayloads.length} payloads from Airtable (call ${apiCallCount})` + ) + + // --- Process and Consolidate Changes --- + if (receivedPayloads.length > 0) { + payloadsFetched += receivedPayloads.length + // Keep the raw payloads for later exposure to the workflow + for (const p of receivedPayloads) { + allPayloads.push(p) + } + let changeCount = 0 + for (const payload of receivedPayloads) { + if (payload.changedTablesById) { + // DEBUG: Log tables being processed + const tableIds = Object.keys(payload.changedTablesById) + logger.debug(`[${requestId}] TRACE: Processing changes for tables`, { + tables: tableIds, + payloadTimestamp: payload.timestamp, + }) + + for (const [tableId, tableChangesUntyped] of Object.entries( + payload.changedTablesById + )) { + const tableChanges = tableChangesUntyped as any // Assert type + + // Handle created records + if (tableChanges.createdRecordsById) { + const createdCount = Object.keys(tableChanges.createdRecordsById).length + changeCount += createdCount + // DEBUG: Log created records count + logger.debug( + `[${requestId}] TRACE: Processing ${createdCount} created records for table ${tableId}` + ) + + for (const [recordId, recordDataUntyped] of Object.entries( + tableChanges.createdRecordsById + )) { + const recordData = recordDataUntyped as any // Assert type + const existingChange = consolidatedChangesMap.get(recordId) + if (existingChange) { + // Record was created and possibly updated within the same batch + existingChange.changedFields = { + ...existingChange.changedFields, + ...(recordData.cellValuesByFieldId || {}), + } + // Keep changeType as 'created' if it started as created + } else { + // New creation + consolidatedChangesMap.set(recordId, { + tableId: tableId, + recordId: recordId, + changeType: 'created', + changedFields: recordData.cellValuesByFieldId || {}, + }) + } + } + } + + // Handle updated records + if (tableChanges.changedRecordsById) { + const updatedCount = Object.keys(tableChanges.changedRecordsById).length + changeCount += updatedCount + // DEBUG: Log updated records count + logger.debug( + `[${requestId}] TRACE: Processing ${updatedCount} updated records for table ${tableId}` + ) + + for (const [recordId, recordDataUntyped] of Object.entries( + tableChanges.changedRecordsById + )) { + const recordData = recordDataUntyped as any // Assert type + const existingChange = consolidatedChangesMap.get(recordId) + const currentFields = recordData.current?.cellValuesByFieldId || {} + + if (existingChange) { + // Existing record was updated again + existingChange.changedFields = { + ...existingChange.changedFields, + ...currentFields, + } + // Ensure type is 'updated' if it was previously 'created' + existingChange.changeType = 'updated' + // Do not update previousFields again + } else { + // First update for this record in the batch + const newChange: AirtableChange = { + tableId: tableId, + recordId: recordId, + changeType: 'updated', + changedFields: currentFields, + } + if (recordData.previous?.cellValuesByFieldId) { + newChange.previousFields = recordData.previous.cellValuesByFieldId + } + consolidatedChangesMap.set(recordId, newChange) + } + } + } + // TODO: Handle deleted records (`destroyedRecordIds`) if needed + } + } + } + + // DEBUG: Log totals for this batch + logger.debug( + `[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount})`, + { + currentMapSize: consolidatedChangesMap.size, + } + ) + } + + const nextCursor = responseBody.cursor + mightHaveMore = responseBody.mightHaveMore || false + + if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { + logger.debug(`[${requestId}] Updating cursor from ${currentCursor} to ${nextCursor}`) + currentCursor = nextCursor + + // Follow exactly the old implementation - use awaited update instead of parallel + const updatedConfig = { + ...localProviderConfig, + externalWebhookCursor: currentCursor, + } + try { + // Force a complete object update to ensure consistency in serverless env + await db + .update(webhook) + .set({ + providerConfig: updatedConfig, // Use full object + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id)) + + localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too + } catch (dbError: any) { + logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { + webhookId: webhookData.id, + cursor: currentCursor, + error: dbError.message, + }) + // Error logging handled by logging session + mightHaveMore = false + throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly + } + } else if (!nextCursor || typeof nextCursor !== 'number') { + logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { + webhookId: webhookData.id, + apiCall: apiCallCount, + receivedCursor: nextCursor, + }) + mightHaveMore = false + } else if (nextCursor === currentCursor) { + logger.debug(`[${requestId}] Cursor hasn't changed (${currentCursor}), stopping poll`) + mightHaveMore = false // Explicitly stop if cursor hasn't changed + } + } catch (fetchError: any) { + logger.error( + `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, + fetchError + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + } + // --- End Polling Loop --- + + // Convert map values to array for final processing + const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) + logger.info( + `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` + ) + + // --- Execute Workflow if we have changes (simplified - no lock check) --- + if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { + try { + // Build input exposing raw payloads and consolidated changes + const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null + const input: any = { + // Raw Airtable payloads as received from the API + payloads: allPayloads, + latestPayload, + // Consolidated, simplified changes for convenience + airtableChanges: finalConsolidatedChanges, + // Include webhook metadata for resolver fallbacks + webhook: { + data: { + provider: 'airtable', + providerConfig: webhookData.providerConfig, + payload: latestPayload, + }, + }, + } + + // CRITICAL EXECUTION TRACE POINT + logger.info( + `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, + { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + timestamp: new Date().toISOString(), + firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', + } + ) + + // Return the processed input for the trigger.dev task to handle + logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + rawPayloadCount: allPayloads.length, + timestamp: new Date().toISOString(), + }) + + return input + } catch (processingError: any) { + logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { + workflowId: workflowData.id, + error: processingError.message, + stack: processingError.stack, + timestamp: new Date().toISOString(), + }) + + throw processingError + } + } else { + // DEBUG: Log when no changes are found + logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { + workflowId: workflowData.id, + apiCallCount, + webhookId: webhookData.id, + }) + } + } catch (error) { + // Catch any unexpected errors during the setup/polling logic itself + logger.error( + `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, + { + webhookId: webhookData.id, + workflowId: workflowData.id, + error: (error as Error).message, + } + ) + // Error logging handled by logging session + } + + // DEBUG: Log function completion + logger.debug(`[${requestId}] TRACE: fetchAndProcessAirtablePayloads completed`, { + totalFetched: payloadsFetched, + totalApiCalls: apiCallCount, + totalChanges: consolidatedChangesMap.size, + timestamp: new Date().toISOString(), + }) +} + +// Define an interface for AirtableChange +export interface AirtableChange { + tableId: string + recordId: string + changeType: 'created' | 'updated' + changedFields: Record // { fieldId: newValue } + previousFields?: Record // { fieldId: previousValue } (optional) +} + +/** + * Configure Gmail polling for a webhook + */ +export async function configureGmailPolling(webhookData: any, requestId: string): Promise { + const logger = createLogger('GmailWebhookSetup') + logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId: string | undefined = providerConfig.credentialId + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + return false + } + + // Get userId from credential + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + ) + return false + } + + const maxEmailsPerPoll = + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : providerConfig.maxEmailsPerPoll || 25 + + const pollingInterval = + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : providerConfig.pollingInterval || 5 + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + ...(credentialId ? { credentialId } : {}), + maxEmailsPerPoll, + pollingInterval, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + labelIds: providerConfig.labelIds || ['INBOX'], + labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id)) + + logger.info( + `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` + ) + return true + } catch (error: any) { + logger.error(`[${requestId}] Failed to configure Gmail polling`, { + webhookId: webhookData.id, + error: error.message, + stack: error.stack, + }) + return false + } +} + +/** + * Configure Outlook polling for a webhook + */ +export async function configureOutlookPolling( + webhookData: any, + requestId: string +): Promise { + const logger = createLogger('OutlookWebhookSetup') + logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId: string | undefined = providerConfig.credentialId + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + return false + } + + // Get userId from credential + const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + ) + return false + } + + const providerCfg = (webhookData.providerConfig as Record) || {} + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerCfg, + userId: effectiveUserId, + ...(credentialId ? { credentialId } : {}), + maxEmailsPerPoll: + typeof providerCfg.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25 + : providerCfg.maxEmailsPerPoll || 25, + pollingInterval: + typeof providerCfg.pollingInterval === 'string' + ? Number.parseInt(providerCfg.pollingInterval, 10) || 5 + : providerCfg.pollingInterval || 5, + markAsRead: providerCfg.markAsRead || false, + includeRawEmail: providerCfg.includeRawEmail || false, + folderIds: providerCfg.folderIds || ['inbox'], + folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id)) + + logger.info( + `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` + ) + return true + } catch (error: any) { + logger.error(`[${requestId}] Failed to configure Outlook polling`, { + webhookId: webhookData.id, + error: error.message, + stack: error.stack, + }) + return false + } +} + +export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined { + if (!twiml) { + return twiml + } + + // Replace [Tag] with and [/Tag] with + return twiml.replace(/\[(\/?[^\]]+)\]/g, '<$1>') +} diff --git a/apps/sim/lib/webhooks/utils.ts b/apps/sim/lib/webhooks/utils.ts index bb031dde86..916045b34b 100644 --- a/apps/sim/lib/webhooks/utils.ts +++ b/apps/sim/lib/webhooks/utils.ts @@ -1,1906 +1,20 @@ -import { db } from '@sim/db' -import { account, webhook } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { createLogger } from '@/lib/logs/console/logger' -import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' - -const logger = createLogger('WebhookUtils') - -/** - * Handle WhatsApp verification requests - */ -export async function handleWhatsAppVerification( - requestId: string, - path: string, - mode: string | null, - token: string | null, - challenge: string | null -): Promise { - if (mode && token && challenge) { - // This is a WhatsApp verification request - logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) - - if (mode !== 'subscribe') { - logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) - return new NextResponse('Invalid mode', { status: 400 }) - } - - // Find all active WhatsApp webhooks - const webhooks = await db - .select() - .from(webhook) - .where(and(eq(webhook.provider, 'whatsapp'), eq(webhook.isActive, true))) - - // Check if any webhook has a matching verification token - for (const wh of webhooks) { - const providerConfig = (wh.providerConfig as Record) || {} - const verificationToken = providerConfig.verificationToken - - if (!verificationToken) { - logger.debug(`[${requestId}] Webhook ${wh.id} has no verification token, skipping`) - continue - } - - if (token === verificationToken) { - logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) - // Return ONLY the challenge as plain text (exactly as WhatsApp expects) - return new NextResponse(challenge, { - status: 200, - headers: { - 'Content-Type': 'text/plain', - }, - }) - } - } - - logger.warn(`[${requestId}] No matching WhatsApp verification token found`) - return new NextResponse('Verification failed', { status: 403 }) - } - - return null -} - -/** - * Handle Slack verification challenges - */ -export function handleSlackChallenge(body: any): NextResponse | null { - if (body.type === 'url_verification' && body.challenge) { - return NextResponse.json({ challenge: body.challenge }) - } - - return null -} - /** - * Validates a Slack webhook request signature using HMAC SHA-256 - * @param signingSecret - Slack signing secret for validation - * @param signature - X-Slack-Signature header value - * @param timestamp - X-Slack-Request-Timestamp header value - * @param body - Raw request body string - * @returns Whether the signature is valid + * Pure utility functions for TwiML processing + * This file has NO server-side dependencies to ensure it can be safely imported in client-side code */ -export async function validateSlackSignature( - signingSecret: string, - signature: string, - timestamp: string, - body: string -): Promise { - try { - // Basic validation first - if (!signingSecret || !signature || !timestamp || !body) { - return false - } - - // Check if the timestamp is too old (> 5 minutes) - const currentTime = Math.floor(Date.now() / 1000) - if (Math.abs(currentTime - Number.parseInt(timestamp)) > 300) { - return false - } - - // Compute the signature - const encoder = new TextEncoder() - const baseString = `v0:${timestamp}:${body}` - - // Create the HMAC with the signing secret - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(signingSecret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ) - - const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(baseString)) - - // Convert the signature to hex - const signatureHex = Array.from(new Uint8Array(signatureBytes)) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - - // Prepare the expected signature format - const computedSignature = `v0=${signatureHex}` - - // Constant-time comparison to prevent timing attacks - if (computedSignature.length !== signature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedSignature.length; i++) { - result |= computedSignature.charCodeAt(i) ^ signature.charCodeAt(i) - } - - return result === 0 - } catch (error) { - logger.error('Error validating Slack signature:', error) - return false - } -} - -/** - * Format Microsoft Teams Graph change notification - */ -async function formatTeamsGraphNotification( - body: any, - foundWebhook: any, - foundWorkflow: any, - request: NextRequest -): Promise { - const notification = body.value[0] - const changeType = notification.changeType || 'created' - const resource = notification.resource || '' - const subscriptionId = notification.subscriptionId || '' - - // Extract chatId and messageId from resource path - let chatId: string | null = null - let messageId: string | null = null - - const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) - if (fullMatch) { - chatId = fullMatch[1] - messageId = fullMatch[2] - } - - if (!chatId || !messageId) { - const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (quotedMatch) { - chatId = quotedMatch[1] - messageId = quotedMatch[2] - } - } - - if (!chatId || !messageId) { - const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) - const rdId = body?.value?.[0]?.resourceData?.id - if (collectionMatch && rdId) { - chatId = collectionMatch[1] - messageId = rdId - } - } - - if ((!chatId || !messageId) && body?.value?.[0]?.resourceData?.['@odata.id']) { - const odataId = String(body.value[0].resourceData['@odata.id']) - const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (odataMatch) { - chatId = odataMatch[1] - messageId = odataMatch[2] - } - } - - if (!chatId || !messageId) { - logger.warn('Could not resolve chatId/messageId from Teams notification', { - resource, - hasResourceDataId: Boolean(body?.value?.[0]?.resourceData?.id), - valueLength: Array.isArray(body?.value) ? body.value.length : 0, - keys: Object.keys(body || {}), - }) - return { - input: 'Teams notification received', - webhook: { - data: { - provider: 'microsoftteams', - path: foundWebhook?.path || '', - providerConfig: foundWebhook?.providerConfig || {}, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - const resolvedChatId = chatId as string - const resolvedMessageId = messageId as string - const providerConfig = (foundWebhook?.providerConfig as Record) || {} - const credentialId = providerConfig.credentialId - const includeAttachments = providerConfig.includeAttachments !== false - - let message: any = null - const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = - [] - let accessToken: string | null = null - - // Teams chat subscriptions require credentials - if (!credentialId) { - logger.error('Missing credentialId for Teams chat subscription', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - webhookId: foundWebhook?.id, - blockId: foundWebhook?.blockId, - providerConfig, - }) - } else { - try { - // Get userId from credential - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) - // Continue without message data - } else { - const effectiveUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded( - credentialId, - effectiveUserId, - 'teams-graph-notification' - ) - } - - if (accessToken) { - const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` - const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) - if (res.ok) { - message = await res.json() - - if (includeAttachments && message?.attachments?.length > 0) { - const attachments = Array.isArray(message?.attachments) ? message.attachments : [] - for (const att of attachments) { - try { - const contentUrl = - typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined - const contentTypeHint = - typeof att?.contentType === 'string' ? (att.contentType as string) : undefined - let attachmentName = (att?.name as string) || 'teams-attachment' - - if (!contentUrl) continue - - let buffer: Buffer | null = null - let mimeType = 'application/octet-stream' - - if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { - try { - const directRes = await fetch(contentUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (directRes.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - const encodedUrl = Buffer.from(contentUrl) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - - const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` - const graphRes = await fetch(graphUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (graphRes.ok) { - const arrayBuffer = await graphRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - graphRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } - } catch { - continue - } - } else if ( - contentUrl.includes('1drv.ms') || - contentUrl.includes('onedrive.live.com') || - contentUrl.includes('onedrive.com') || - contentUrl.includes('my.microsoftpersonalcontent.com') - ) { - try { - let shareToken: string | null = null - - if (contentUrl.includes('1drv.ms')) { - const urlParts = contentUrl.split('/').pop() - if (urlParts) shareToken = urlParts - } else if (contentUrl.includes('resid=')) { - const urlParams = new URL(contentUrl).searchParams - const resId = urlParams.get('resid') - if (resId) shareToken = resId - } - - if (!shareToken) { - const base64Url = Buffer.from(contentUrl, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } else if (!shareToken.startsWith('u!')) { - const base64Url = Buffer.from(shareToken, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } - - const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` - const metadataRes = await fetch(metadataUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!metadataRes.ok) { - const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` - const directRes = await fetch(directUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (directRes.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } else { - const metadata = await metadataRes.json() - const downloadUrl = metadata['@microsoft.graph.downloadUrl'] - - if (downloadUrl) { - const downloadRes = await fetch(downloadUrl) - - if (downloadRes.ok) { - const arrayBuffer = await downloadRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - downloadRes.headers.get('content-type') || - metadata.file?.mimeType || - contentTypeHint || - 'application/octet-stream' - - if (metadata.name && metadata.name !== attachmentName) { - attachmentName = metadata.name - } - } else { - continue - } - } else { - continue - } - } - } catch { - continue - } - } else { - try { - const ares = await fetch(contentUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - if (ares.ok) { - const arrayBuffer = await ares.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - ares.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } - } catch { - continue - } - } - - if (!buffer) continue - - const size = buffer.length - - // Store raw attachment (will be uploaded to execution storage later) - rawAttachments.push({ - name: attachmentName, - data: buffer, - contentType: mimeType, - size, - }) - } catch {} - } - } - } - } - } catch (error) { - logger.error('Failed to fetch Teams message', { - error, - chatId: resolvedChatId, - messageId: resolvedMessageId, - }) - } - } - - // If no message was fetched, return minimal data - if (!message) { - logger.warn('No message data available for Teams notification', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - hasCredential: !!credentialId, - }) - return { - input: '', - message_id: messageId, - chat_id: chatId, - from_name: 'Unknown', - text: '', - created_at: notification.resourceData?.createdDateTime || '', - change_type: changeType, - subscription_id: subscriptionId, - attachments: [], - microsoftteams: { - message: { id: messageId, text: '', timestamp: '', chatId, raw: null }, - from: { id: '', name: 'Unknown', aadObjectId: '' }, - notification: { changeType, subscriptionId, resource }, - }, - webhook: { - data: { - provider: 'microsoftteams', - path: foundWebhook?.path || '', - providerConfig: foundWebhook?.providerConfig || {}, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - // Extract data from message - we know it exists now - // body.content is the HTML/text content, summary is a plain text preview (max 280 chars) - const messageText = message.body?.content || '' - const from = message.from?.user || {} - const createdAt = message.createdDateTime || '' - - return { - input: messageText, - message_id: messageId, - chat_id: chatId, - from_name: from.displayName || 'Unknown', - text: messageText, - created_at: createdAt, - change_type: changeType, - subscription_id: subscriptionId, - attachments: rawAttachments, - microsoftteams: { - message: { - id: messageId, - text: messageText, - timestamp: createdAt, - chatId, - raw: message, - }, - from: { - id: from.id, - name: from.displayName, - aadObjectId: from.aadObjectId, - }, - notification: { - changeType, - subscriptionId, - resource, - }, - }, - webhook: { - data: { - provider: 'microsoftteams', - path: foundWebhook?.path || '', - providerConfig: foundWebhook?.providerConfig || {}, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } -} - /** - * Format webhook input based on provider + * Convert square bracket notation to TwiML XML tags + * Used by Twilio Voice tools to allow LLMs to generate TwiML without XML escaping issues + * + * @example + * "[Response][Say]Hello[/Say][/Response]" + * -> "Hello" */ -export async function formatWebhookInput( - foundWebhook: any, - foundWorkflow: any, - body: any, - request: NextRequest -): Promise { - if (foundWebhook.provider === 'whatsapp') { - const data = body?.entry?.[0]?.changes?.[0]?.value - const messages = data?.messages || [] - - if (messages.length > 0) { - const message = messages[0] - const phoneNumberId = data.metadata?.phone_number_id - const from = message.from - const messageId = message.id - const timestamp = message.timestamp - const text = message.text?.body - - return { - whatsapp: { - data: { - messageId, - from, - phoneNumberId, - text, - timestamp, - raw: message, - }, - }, - webhook: { - data: { - provider: 'whatsapp', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - return null - } - - if (foundWebhook.provider === 'telegram') { - const message = - body?.message || body?.edited_message || body?.channel_post || body?.edited_channel_post - - if (message) { - let input = '' - - if (message.text) { - input = message.text - } else if (message.caption) { - input = message.caption - } else if (message.photo) { - input = 'Photo message' - } else if (message.document) { - input = `Document: ${message.document.file_name || 'file'}` - } else if (message.audio) { - input = `Audio: ${message.audio.title || 'audio file'}` - } else if (message.video) { - input = 'Video message' - } else if (message.voice) { - input = 'Voice message' - } else if (message.sticker) { - input = `Sticker: ${message.sticker.emoji || '🎭'}` - } else if (message.location) { - input = 'Location shared' - } else if (message.contact) { - input = `Contact: ${message.contact.first_name || 'contact'}` - } else if (message.poll) { - input = `Poll: ${message.poll.question}` - } else { - input = 'Message received' - } - - const messageObj = { - id: message.message_id, - text: message.text, - caption: message.caption, - date: message.date, - messageType: message.photo - ? 'photo' - : message.document - ? 'document' - : message.audio - ? 'audio' - : message.video - ? 'video' - : message.voice - ? 'voice' - : message.sticker - ? 'sticker' - : message.location - ? 'location' - : message.contact - ? 'contact' - : message.poll - ? 'poll' - : 'text', - raw: message, - } - - const senderObj = message.from - ? { - id: message.from.id, - firstName: message.from.first_name, - lastName: message.from.last_name, - username: message.from.username, - languageCode: message.from.language_code, - isBot: message.from.is_bot, - } - : null - - const chatObj = message.chat - ? { - id: message.chat.id, - type: message.chat.type, - title: message.chat.title, - username: message.chat.username, - firstName: message.chat.first_name, - lastName: message.chat.last_name, - } - : null - - return { - input, - - // Top-level properties for backward compatibility with syntax - message: messageObj, - sender: senderObj, - chat: chatObj, - updateId: body.update_id, - updateType: body.message - ? 'message' - : body.edited_message - ? 'edited_message' - : body.channel_post - ? 'channel_post' - : body.edited_channel_post - ? 'edited_channel_post' - : 'unknown', - - // Keep the nested structure for the new telegram.message.text syntax - telegram: { - message: messageObj, - sender: senderObj, - chat: chatObj, - updateId: body.update_id, - updateType: body.message - ? 'message' - : body.edited_message - ? 'edited_message' - : body.channel_post - ? 'channel_post' - : body.edited_channel_post - ? 'edited_channel_post' - : 'unknown', - }, - webhook: { - data: { - provider: 'telegram', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - // Fallback for unknown Telegram update types - logger.warn('Unknown Telegram update type', { - updateId: body.update_id, - bodyKeys: Object.keys(body || {}), - }) - - return { - input: 'Telegram update received', - telegram: { - updateId: body.update_id, - updateType: 'unknown', - raw: body, - }, - webhook: { - data: { - provider: 'telegram', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - if (foundWebhook.provider === 'gmail') { - if (body && typeof body === 'object' && 'email' in body) { - return body // { email: {...}, timestamp: ... } - } - return body - } - - if (foundWebhook.provider === 'outlook') { - if (body && typeof body === 'object' && 'email' in body) { - return body // { email: {...}, timestamp: ... } - } - return body - } - - if (foundWebhook.provider === 'microsoftteams') { - // Check if this is a Microsoft Graph change notification - if (body?.value && Array.isArray(body.value) && body.value.length > 0) { - return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request) - } - - // Microsoft Teams outgoing webhook - Teams sending data to us - const messageText = body?.text || '' - const messageId = body?.id || '' - const timestamp = body?.timestamp || body?.localTimestamp || '' - const from = body?.from || {} - const conversation = body?.conversation || {} - - // Construct the message object - const messageObj = { - raw: { - attachments: body?.attachments || [], - channelData: body?.channelData || {}, - conversation: body?.conversation || {}, - text: messageText, - messageType: body?.type || 'message', - channelId: body?.channelId || '', - timestamp, - }, - } - - // Construct the from object - const fromObj = { - id: from.id || '', - name: from.name || '', - aadObjectId: from.aadObjectId || '', - } - - // Construct the conversation object - const conversationObj = { - id: conversation.id || '', - name: conversation.name || '', - isGroup: conversation.isGroup || false, - tenantId: conversation.tenantId || '', - aadObjectId: conversation.aadObjectId || '', - conversationType: conversation.conversationType || '', - } - - // Construct the activity object - const activityObj = body || {} - - return { - input: messageText, // Primary workflow input - the message text - - // Top-level properties for direct access with syntax - from: fromObj, - message: messageObj, - activity: activityObj, - conversation: conversationObj, - - webhook: { - data: { - provider: 'microsoftteams', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - if (foundWebhook.provider === 'slack') { - // Slack input formatting logic - check for valid event - const event = body?.event - - if (event && body?.type === 'event_callback') { - // Extract event text with fallbacks for different event types - let input = '' - - if (event.text) { - input = event.text - } else if (event.type === 'app_mention') { - input = 'App mention received' - } else { - input = 'Slack event received' - } - - // Create the event object for easier access - const eventObj = { - event_type: event.type || '', - channel: event.channel || '', - channel_name: '', // Could be resolved via additional API calls if needed - user: event.user || '', - user_name: '', // Could be resolved via additional API calls if needed - text: event.text || '', - timestamp: event.ts || event.event_ts || '', - team_id: body.team_id || event.team || '', - event_id: body.event_id || '', - } - - return { - input, // Primary workflow input - the event content - - // // // Top-level properties for backward compatibility with syntax - event: eventObj, - - // Keep the nested structure for the new slack.event.text syntax - slack: { - event: eventObj, - }, - webhook: { - data: { - provider: 'slack', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - // Fallback for unknown Slack event types - logger.warn('Unknown Slack event type', { - type: body?.type, - hasEvent: !!body?.event, - bodyKeys: Object.keys(body || {}), - }) - - return { - input: 'Slack webhook received', - slack: { - event: { - event_type: body?.event?.type || body?.type || 'unknown', - channel: body?.event?.channel || '', - user: body?.event?.user || '', - text: body?.event?.text || '', - timestamp: body?.event?.ts || '', - team_id: body?.team_id || '', - event_id: body?.event_id || '', - }, - }, - webhook: { - data: { - provider: 'slack', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - if (foundWebhook.provider === 'webflow') { - const triggerType = body?.triggerType || 'unknown' - const siteId = body?.siteId || '' - const workspaceId = body?.workspaceId || '' - const collectionId = body?.collectionId || '' - const payload = body?.payload || {} - const formId = body?.formId || '' - const formName = body?.name || '' - const formSubmissionId = body?.id || '' - const submittedAt = body?.submittedAt || '' - const formData = body?.data || {} - const schema = body?.schema || {} - - return { - siteId, - workspaceId, - collectionId, - payload, - triggerType, - - formId, - name: formName, - id: formSubmissionId, - submittedAt, - data: formData, - schema, - formElementId: body?.formElementId || '', - - webflow: { - siteId, - workspaceId, - collectionId, - payload, - triggerType, - raw: body, - }, - - webhook: { - data: { - provider: 'webflow', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - if (foundWebhook.provider === 'generic') { - return body - } - - if (foundWebhook.provider === 'google_forms') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - // Normalize answers: if value is an array with single element, collapse to scalar; keep multi-select arrays - const normalizeAnswers = (src: unknown): Record => { - if (!src || typeof src !== 'object') return {} - const out: Record = {} - for (const [k, v] of Object.entries(src as Record)) { - if (Array.isArray(v)) { - out[k] = v.length === 1 ? v[0] : v - } else { - out[k] = v as unknown - } - } - return out - } - - const responseId = body?.responseId || body?.id || '' - const createTime = body?.createTime || body?.timestamp || new Date().toISOString() - const lastSubmittedTime = body?.lastSubmittedTime || createTime - const formId = body?.formId || providerConfig.formId || '' - const includeRaw = providerConfig.includeRawPayload !== false - - const normalizedAnswers = normalizeAnswers(body?.answers) - - const summaryCount = Object.keys(normalizedAnswers).length - const input = `Google Form response${responseId ? ` ${responseId}` : ''} (${summaryCount} answers)` - - return { - input, - responseId, - createTime, - lastSubmittedTime, - formId, - answers: normalizedAnswers, - ...(includeRaw ? { raw: body?.raw ?? body } : {}), - google_forms: { - responseId, - createTime, - lastSubmittedTime, - formId, - answers: normalizedAnswers, - ...(includeRaw ? { raw: body?.raw ?? body } : {}), - }, - webhook: { - data: { - provider: 'google_forms', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: includeRaw ? body : undefined, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - if (foundWebhook.provider === 'github') { - // GitHub webhook input formatting logic - const eventType = request.headers.get('x-github-event') || 'unknown' - const delivery = request.headers.get('x-github-delivery') || '' - - // Extract common GitHub properties - const repository = body?.repository || {} - const sender = body?.sender || {} - const action = body?.action || '' - - // Build GitHub-specific variables based on the trigger config outputs - const githubData = { - // Event metadata - event_type: eventType, - action: action, - delivery_id: delivery, - - // Repository information (avoid 'repository' to prevent conflict with the object) - repository_full_name: repository.full_name || '', - repository_name: repository.name || '', - repository_owner: repository.owner?.login || '', - repository_id: repository.id || '', - repository_url: repository.html_url || '', - - // Sender information (avoid 'sender' to prevent conflict with the object) - sender_login: sender.login || '', - sender_id: sender.id || '', - sender_type: sender.type || '', - sender_url: sender.html_url || '', - - // Event-specific data - ...(body?.ref && { - ref: body.ref, - branch: body.ref?.replace('refs/heads/', '') || '', - }), - ...(body?.before && { before: body.before }), - ...(body?.after && { after: body.after }), - ...(body?.commits && { - commits: JSON.stringify(body.commits), - commit_count: body.commits.length || 0, - }), - ...(body?.head_commit && { - commit_message: body.head_commit.message || '', - commit_author: body.head_commit.author?.name || '', - commit_sha: body.head_commit.id || '', - commit_url: body.head_commit.url || '', - }), - ...(body?.pull_request && { - pull_request: JSON.stringify(body.pull_request), - pr_number: body.pull_request.number || '', - pr_title: body.pull_request.title || '', - pr_state: body.pull_request.state || '', - pr_url: body.pull_request.html_url || '', - }), - ...(body?.issue && { - issue: JSON.stringify(body.issue), - issue_number: body.issue.number || '', - issue_title: body.issue.title || '', - issue_state: body.issue.state || '', - issue_url: body.issue.html_url || '', - }), - ...(body?.comment && { - comment: JSON.stringify(body.comment), - comment_body: body.comment.body || '', - comment_url: body.comment.html_url || '', - }), - } - - // Set input based on event type for workflow processing - let input = '' - switch (eventType) { - case 'push': - input = `Push to ${githubData.branch || githubData.ref}: ${githubData.commit_message || 'No commit message'}` - break - case 'pull_request': - input = `${action} pull request: ${githubData.pr_title || 'No title'}` - break - case 'issues': - input = `${action} issue: ${githubData.issue_title || 'No title'}` - break - case 'issue_comment': - case 'pull_request_review_comment': - input = `Comment ${action}: ${githubData.comment_body?.slice(0, 100) || 'No comment body'}${(githubData.comment_body?.length || 0) > 100 ? '...' : ''}` - break - default: - input = `GitHub ${eventType} event${action ? ` (${action})` : ''}` - } - - return { - // Expose raw GitHub payload at the root - ...body, - // Include webhook metadata alongside - webhook: { - data: { - provider: 'github', - path: foundWebhook.path, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } - } - - // Generic format for other providers - return { - webhook: { - data: { - path: foundWebhook.path, - provider: foundWebhook.provider, - providerConfig: foundWebhook.providerConfig, - payload: body, - headers: Object.fromEntries(request.headers.entries()), - method: request.method, - }, - }, - workflowId: foundWorkflow.id, - } -} - -/** - * Validates a Microsoft Teams outgoing webhook request signature using HMAC SHA-256 - * @param hmacSecret - Microsoft Teams HMAC secret (base64 encoded) - * @param signature - Authorization header value (should start with 'HMAC ') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateMicrosoftTeamsSignature( - hmacSecret: string, - signature: string, - body: string -): boolean { - try { - // Basic validation first - if (!hmacSecret || !signature || !body) { - return false - } - - // Check if signature has correct format - if (!signature.startsWith('HMAC ')) { - return false - } - - const providedSignature = signature.substring(5) // Remove 'HMAC ' prefix - - // Compute HMAC SHA256 signature using Node.js crypto - const crypto = require('crypto') - const secretBytes = Buffer.from(hmacSecret, 'base64') - const bodyBytes = Buffer.from(body, 'utf8') - const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') - - // Constant-time comparison to prevent timing attacks - if (computedHash.length !== providedSignature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) - } - - return result === 0 - } catch (error) { - logger.error('Error validating Microsoft Teams signature:', error) - return false - } -} - -/** - * Process webhook provider-specific verification - */ -export function verifyProviderWebhook( - foundWebhook: any, - request: NextRequest, - requestId: string -): NextResponse | null { - const authHeader = request.headers.get('authorization') - const providerConfig = (foundWebhook.providerConfig as Record) || {} - switch (foundWebhook.provider) { - case 'github': - break - case 'stripe': - break - case 'gmail': - if (providerConfig.secret) { - const secretHeader = request.headers.get('X-Webhook-Secret') - if (!secretHeader || secretHeader.length !== providerConfig.secret.length) { - logger.warn(`[${requestId}] Invalid Gmail webhook secret`) - return new NextResponse('Unauthorized', { status: 401 }) - } - let result = 0 - for (let i = 0; i < secretHeader.length; i++) { - result |= secretHeader.charCodeAt(i) ^ providerConfig.secret.charCodeAt(i) - } - if (result !== 0) { - logger.warn(`[${requestId}] Invalid Gmail webhook secret`) - return new NextResponse('Unauthorized', { status: 401 }) - } - } - break - case 'telegram': { - // Check User-Agent to ensure it's not blocked by middleware - const userAgent = request.headers.get('user-agent') || '' - logger.debug(`[${requestId}] Telegram webhook request received with User-Agent: ${userAgent}`) - - if (!userAgent) { - logger.warn( - `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` - ) - } - - // Telegram uses IP addresses in specific ranges - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - logger.debug(`[${requestId}] Telegram webhook request from IP: ${clientIp}`) - - break - } - case 'microsoftteams': - break - case 'generic': - if (providerConfig.requireAuth) { - let isAuthenticated = false - if (providerConfig.token) { - const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null - if (providedToken === providerConfig.token) { - isAuthenticated = true - } - if (!isAuthenticated && providerConfig.secretHeaderName) { - const customHeaderValue = request.headers.get(providerConfig.secretHeaderName) - if (customHeaderValue === providerConfig.token) { - isAuthenticated = true - } - } - if (!isAuthenticated) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized', { status: 401 }) - } - } - } - if ( - providerConfig.allowedIps && - Array.isArray(providerConfig.allowedIps) && - providerConfig.allowedIps.length > 0 - ) { - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) { - logger.warn( - `[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}` - ) - return new NextResponse('Forbidden - IP not allowed', { - status: 403, - }) - } - } - break - default: - if (providerConfig.token) { - const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null - if (!providedToken || providedToken !== providerConfig.token) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized', { status: 401 }) - } - } - } - - return null -} - -/** - * Process Airtable payloads - */ -export async function fetchAndProcessAirtablePayloads( - webhookData: any, - workflowData: any, - requestId: string // Original request ID from the ping, used for the final execution log -) { - // Logging handles all error logging - let currentCursor: number | null = null - let mightHaveMore = true - let payloadsFetched = 0 - let apiCallCount = 0 - // Use a Map to consolidate changes per record ID - const consolidatedChangesMap = new Map() - // Capture raw payloads from Airtable for exposure to workflows - const allPayloads = [] - const localProviderConfig = { - ...((webhookData.providerConfig as Record) || {}), - } - - try { - // --- Essential IDs & Config from localProviderConfig --- - const baseId = localProviderConfig.baseId - const airtableWebhookId = localProviderConfig.externalId - - if (!baseId || !airtableWebhookId) { - logger.error( - `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` - ) - return - } - - const credentialId: string | undefined = localProviderConfig.credentialId - if (!credentialId) { - logger.error( - `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` - ) - return - } - - let ownerUserId: string | null = null - try { - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - ownerUserId = rows.length ? rows[0].userId : null - } catch (_e) { - ownerUserId = null - } - - if (!ownerUserId) { - logger.error( - `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` - ) - return - } - - const storedCursor = localProviderConfig.externalWebhookCursor - - if (storedCursor === undefined || storedCursor === null) { - logger.info( - `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` - ) - localProviderConfig.externalWebhookCursor = null - - try { - await db - .update(webhook) - .set({ - providerConfig: { - ...localProviderConfig, - externalWebhookCursor: null, - }, - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = null - logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) - } catch (initError: any) { - logger.error(`[${requestId}] Failed to initialize cursor in DB`, { - webhookId: webhookData.id, - error: initError.message, - stack: initError.stack, - }) - } - } - - if (storedCursor && typeof storedCursor === 'number') { - currentCursor = storedCursor - logger.debug( - `[${requestId}] Using stored cursor: ${currentCursor} for webhook ${webhookData.id}` - ) - } else { - currentCursor = null - logger.debug( - `[${requestId}] No valid stored cursor for webhook ${webhookData.id}, starting from beginning` - ) - } - - let accessToken: string | null = null - try { - accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` - ) - throw new Error('Airtable access token not found.') - } - - logger.info(`[${requestId}] Successfully obtained Airtable access token`) - } catch (tokenError: any) { - logger.error( - `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, - { - error: tokenError.message, - stack: tokenError.stack, - credentialId, - } - ) - return - } - - const airtableApiBase = 'https://api.airtable.com/v0' - - // --- Polling Loop --- - while (mightHaveMore) { - apiCallCount++ - // Safety break - if (apiCallCount > 10) { - logger.warn(`[${requestId}] Reached maximum polling limit (10 calls)`, { - webhookId: webhookData.id, - consolidatedCount: consolidatedChangesMap.size, - }) - mightHaveMore = false - break - } - - const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` - const queryParams = new URLSearchParams() - if (currentCursor !== null) { - queryParams.set('cursor', currentCursor.toString()) - } - const fullUrl = `${apiUrl}?${queryParams.toString()}` - - logger.debug(`[${requestId}] Fetching Airtable payloads (call ${apiCallCount})`, { - url: fullUrl, - webhookId: webhookData.id, - }) - - try { - const fetchStartTime = Date.now() - const response = await fetch(fullUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - // DEBUG: Log API response time - logger.debug(`[${requestId}] TRACE: Airtable API response received`, { - status: response.status, - duration: `${Date.now() - fetchStartTime}ms`, - hasBody: true, - apiCall: apiCallCount, - }) - - const responseBody = await response.json() - - if (!response.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || - responseBody.error || - `Airtable API error Status ${response.status}` - logger.error( - `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, - { - webhookId: webhookData.id, - status: response.status, - error: errorMessage, - } - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - - const receivedPayloads = responseBody.payloads || [] - logger.debug( - `[${requestId}] Received ${receivedPayloads.length} payloads from Airtable (call ${apiCallCount})` - ) - - // --- Process and Consolidate Changes --- - if (receivedPayloads.length > 0) { - payloadsFetched += receivedPayloads.length - // Keep the raw payloads for later exposure to the workflow - for (const p of receivedPayloads) { - allPayloads.push(p) - } - let changeCount = 0 - for (const payload of receivedPayloads) { - if (payload.changedTablesById) { - // DEBUG: Log tables being processed - const tableIds = Object.keys(payload.changedTablesById) - logger.debug(`[${requestId}] TRACE: Processing changes for tables`, { - tables: tableIds, - payloadTimestamp: payload.timestamp, - }) - - for (const [tableId, tableChangesUntyped] of Object.entries( - payload.changedTablesById - )) { - const tableChanges = tableChangesUntyped as any // Assert type - - // Handle created records - if (tableChanges.createdRecordsById) { - const createdCount = Object.keys(tableChanges.createdRecordsById).length - changeCount += createdCount - // DEBUG: Log created records count - logger.debug( - `[${requestId}] TRACE: Processing ${createdCount} created records for table ${tableId}` - ) - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.createdRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - if (existingChange) { - // Record was created and possibly updated within the same batch - existingChange.changedFields = { - ...existingChange.changedFields, - ...(recordData.cellValuesByFieldId || {}), - } - // Keep changeType as 'created' if it started as created - } else { - // New creation - consolidatedChangesMap.set(recordId, { - tableId: tableId, - recordId: recordId, - changeType: 'created', - changedFields: recordData.cellValuesByFieldId || {}, - }) - } - } - } - - // Handle updated records - if (tableChanges.changedRecordsById) { - const updatedCount = Object.keys(tableChanges.changedRecordsById).length - changeCount += updatedCount - // DEBUG: Log updated records count - logger.debug( - `[${requestId}] TRACE: Processing ${updatedCount} updated records for table ${tableId}` - ) - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.changedRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - const currentFields = recordData.current?.cellValuesByFieldId || {} - - if (existingChange) { - // Existing record was updated again - existingChange.changedFields = { - ...existingChange.changedFields, - ...currentFields, - } - // Ensure type is 'updated' if it was previously 'created' - existingChange.changeType = 'updated' - // Do not update previousFields again - } else { - // First update for this record in the batch - const newChange: AirtableChange = { - tableId: tableId, - recordId: recordId, - changeType: 'updated', - changedFields: currentFields, - } - if (recordData.previous?.cellValuesByFieldId) { - newChange.previousFields = recordData.previous.cellValuesByFieldId - } - consolidatedChangesMap.set(recordId, newChange) - } - } - } - // TODO: Handle deleted records (`destroyedRecordIds`) if needed - } - } - } - - // DEBUG: Log totals for this batch - logger.debug( - `[${requestId}] TRACE: Processed ${changeCount} changes in API call ${apiCallCount})`, - { - currentMapSize: consolidatedChangesMap.size, - } - ) - } - - const nextCursor = responseBody.cursor - mightHaveMore = responseBody.mightHaveMore || false - - if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { - logger.debug(`[${requestId}] Updating cursor from ${currentCursor} to ${nextCursor}`) - currentCursor = nextCursor - - // Follow exactly the old implementation - use awaited update instead of parallel - const updatedConfig = { - ...localProviderConfig, - externalWebhookCursor: currentCursor, - } - try { - // Force a complete object update to ensure consistency in serverless env - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, // Use full object - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too - } catch (dbError: any) { - logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { - webhookId: webhookData.id, - cursor: currentCursor, - error: dbError.message, - }) - // Error logging handled by logging session - mightHaveMore = false - throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly - } - } else if (!nextCursor || typeof nextCursor !== 'number') { - logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { - webhookId: webhookData.id, - apiCall: apiCallCount, - receivedCursor: nextCursor, - }) - mightHaveMore = false - } else if (nextCursor === currentCursor) { - logger.debug(`[${requestId}] Cursor hasn't changed (${currentCursor}), stopping poll`) - mightHaveMore = false // Explicitly stop if cursor hasn't changed - } - } catch (fetchError: any) { - logger.error( - `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, - fetchError - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - } - // --- End Polling Loop --- - - // Convert map values to array for final processing - const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) - logger.info( - `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` - ) - - // --- Execute Workflow if we have changes (simplified - no lock check) --- - if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { - try { - // Build input exposing raw payloads and consolidated changes - const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null - const input: any = { - // Raw Airtable payloads as received from the API - payloads: allPayloads, - latestPayload, - // Consolidated, simplified changes for convenience - airtableChanges: finalConsolidatedChanges, - // Include webhook metadata for resolver fallbacks - webhook: { - data: { - provider: 'airtable', - providerConfig: webhookData.providerConfig, - payload: latestPayload, - }, - }, - } - - // CRITICAL EXECUTION TRACE POINT - logger.info( - `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, - { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - timestamp: new Date().toISOString(), - firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', - } - ) - - // Return the processed input for the trigger.dev task to handle - logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - rawPayloadCount: allPayloads.length, - timestamp: new Date().toISOString(), - }) - - return input - } catch (processingError: any) { - logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { - workflowId: workflowData.id, - error: processingError.message, - stack: processingError.stack, - timestamp: new Date().toISOString(), - }) - - throw processingError - } - } else { - // DEBUG: Log when no changes are found - logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { - workflowId: workflowData.id, - apiCallCount, - webhookId: webhookData.id, - }) - } - } catch (error) { - // Catch any unexpected errors during the setup/polling logic itself - logger.error( - `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, - { - webhookId: webhookData.id, - workflowId: workflowData.id, - error: (error as Error).message, - } - ) - // Error logging handled by logging session - } - - // DEBUG: Log function completion - logger.debug(`[${requestId}] TRACE: fetchAndProcessAirtablePayloads completed`, { - totalFetched: payloadsFetched, - totalApiCalls: apiCallCount, - totalChanges: consolidatedChangesMap.size, - timestamp: new Date().toISOString(), - }) -} - -// Define an interface for AirtableChange -export interface AirtableChange { - tableId: string - recordId: string - changeType: 'created' | 'updated' - changedFields: Record // { fieldId: newValue } - previousFields?: Record // { fieldId: previousValue } (optional) -} - -/** - * Configure Gmail polling for a webhook - */ -export async function configureGmailPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('GmailWebhookSetup') - logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) - return false - } - - // Get userId from credential - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) - return false - } - - const maxEmailsPerPoll = - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25 - - const pollingInterval = - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5 - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - ...(credentialId ? { credentialId } : {}), - maxEmailsPerPoll, - pollingInterval, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - labelIds: providerConfig.labelIds || ['INBOX'], - labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Gmail polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure Outlook polling for a webhook - */ -export async function configureOutlookPolling( - webhookData: any, - requestId: string -): Promise { - const logger = createLogger('OutlookWebhookSetup') - logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) - return false - } - - // Get userId from credential - const rows = await db.select().from(account).where(eq(account.id, credentialId)).limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - const accessToken = await refreshAccessTokenIfNeeded(credentialId, effectiveUserId, requestId) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) - return false - } - - const providerCfg = (webhookData.providerConfig as Record) || {} - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerCfg, - userId: effectiveUserId, - ...(credentialId ? { credentialId } : {}), - maxEmailsPerPoll: - typeof providerCfg.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerCfg.maxEmailsPerPoll, 10) || 25 - : providerCfg.maxEmailsPerPoll || 25, - pollingInterval: - typeof providerCfg.pollingInterval === 'string' - ? Number.parseInt(providerCfg.pollingInterval, 10) || 5 - : providerCfg.pollingInterval || 5, - markAsRead: providerCfg.markAsRead || false, - includeRawEmail: providerCfg.includeRawEmail || false, - folderIds: providerCfg.folderIds || ['inbox'], - folderFilterBehavior: providerCfg.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Outlook polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false +export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined { + if (!twiml) { + return twiml } + // Replace [Tag] with and [/Tag] with + return twiml.replace(/\[(\/?[^\]]+)\]/g, '<$1>') } diff --git a/apps/sim/lib/workflows/db-helpers.test.ts b/apps/sim/lib/workflows/db-helpers.test.ts index 06534359ce..6e2a5b9d31 100644 --- a/apps/sim/lib/workflows/db-helpers.test.ts +++ b/apps/sim/lib/workflows/db-helpers.test.ts @@ -71,6 +71,12 @@ vi.doMock('drizzle-orm', () => ({ eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), and: vi.fn((...conditions) => ({ type: 'and', conditions })), desc: vi.fn((field) => ({ field, type: 'desc' })), + sql: vi.fn((strings, ...values) => ({ + strings, + values, + type: 'sql', + _: { brand: 'SQL' }, + })), })) vi.doMock('@/lib/logs/console/logger', () => ({ diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index f96488416f..bad640e081 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -469,7 +469,6 @@ async function handleInternalRequest( const fullUrl = fullUrlObj.toString() - // For custom tools, validate parameters on the client side before sending if (toolId.startsWith('custom_') && tool.request.body) { const requestBody = tool.request.body(params) if (requestBody.schema && requestBody.params) { diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 05673c1c94..0e70a643b5 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -194,6 +194,7 @@ import { } from '@/tools/telegram' import { thinkingTool } from '@/tools/thinking' import { sendSMSTool } from '@/tools/twilio' +import { getRecordingTool, listCallsTool, makeCallTool } from '@/tools/twilio_voice' import { typeformFilesTool, typeformInsightsTool, typeformResponsesTool } from '@/tools/typeform' import type { ToolConfig } from '@/tools/types' import { visionTool } from '@/tools/vision' @@ -350,6 +351,9 @@ export const tools: Record = { confluence_retrieve: confluenceRetrieveTool, confluence_update: confluenceUpdateTool, twilio_send_sms: sendSMSTool, + twilio_voice_make_call: makeCallTool, + twilio_voice_list_calls: listCallsTool, + twilio_voice_get_recording: getRecordingTool, airtable_create_records: airtableCreateRecordsTool, airtable_get_record: airtableGetRecordTool, airtable_list_records: airtableListRecordsTool, diff --git a/apps/sim/tools/twilio_voice/get_recording.ts b/apps/sim/tools/twilio_voice/get_recording.ts new file mode 100644 index 0000000000..616c375107 --- /dev/null +++ b/apps/sim/tools/twilio_voice/get_recording.ts @@ -0,0 +1,164 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { TwilioGetRecordingOutput, TwilioGetRecordingParams } from '@/tools/twilio_voice/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('TwilioVoiceGetRecordingTool') + +export const getRecordingTool: ToolConfig = { + id: 'twilio_voice_get_recording', + name: 'Twilio Voice Get Recording', + description: 'Retrieve call recording information and transcription (if enabled via TwiML).', + version: '1.0.0', + + params: { + recordingSid: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Recording SID to retrieve', + }, + accountSid: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Twilio Account SID', + }, + authToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Twilio Auth Token', + }, + }, + + request: { + url: (params) => { + if (!params.accountSid || !params.recordingSid) { + throw new Error('Twilio Account SID and Recording SID are required') + } + if (!params.accountSid.startsWith('AC')) { + throw new Error( + `Invalid Account SID format. Account SID must start with "AC" (you provided: ${params.accountSid.substring(0, 2)}...)` + ) + } + return `https://api.twilio.com/2010-04-01/Accounts/${params.accountSid}/Recordings/${params.recordingSid}.json` + }, + method: 'GET', + headers: (params) => { + if (!params.accountSid || !params.authToken) { + throw new Error('Twilio credentials are required') + } + const authToken = Buffer.from(`${params.accountSid}:${params.authToken}`).toString('base64') + return { + Authorization: `Basic ${authToken}`, + } + }, + }, + + transformResponse: async (response, params) => { + const data = await response.json() + + logger.info('Twilio Get Recording Response:', data) + + if (data.error_code) { + return { + success: false, + output: { + success: false, + error: data.message || data.error_message || 'Failed to retrieve recording', + }, + error: data.message || data.error_message || 'Failed to retrieve recording', + } + } + + const baseUrl = 'https://api.twilio.com' + const mediaUrl = data.uri ? `${baseUrl}${data.uri.replace('.json', '')}` : undefined + + let transcriptionText: string | undefined + let transcriptionStatus: string | undefined + let transcriptionPrice: string | undefined + let transcriptionPriceUnit: string | undefined + + try { + const authToken = Buffer.from(`${params?.accountSid}:${params?.authToken}`).toString('base64') + + const transcriptionUrl = `https://api.twilio.com/2010-04-01/Accounts/${params?.accountSid}/Transcriptions.json?RecordingSid=${data.sid}` + logger.info('Checking for transcriptions:', transcriptionUrl) + + const transcriptionResponse = await fetch(transcriptionUrl, { + method: 'GET', + headers: { Authorization: `Basic ${authToken}` }, + }) + + if (transcriptionResponse.ok) { + const transcriptionData = await transcriptionResponse.json() + logger.info('Transcription response:', JSON.stringify(transcriptionData)) + + if (transcriptionData.transcriptions && transcriptionData.transcriptions.length > 0) { + const transcription = transcriptionData.transcriptions[0] + transcriptionText = transcription.transcription_text + transcriptionStatus = transcription.status + transcriptionPrice = transcription.price + transcriptionPriceUnit = transcription.price_unit + logger.info('Transcription found:', { + status: transcriptionStatus, + textLength: transcriptionText?.length, + }) + } else { + logger.info( + 'No transcriptions found. To enable transcription, use in your TwiML.' + ) + } + } + } catch (error) { + logger.warn('Failed to fetch transcription:', error) + } + + return { + success: true, + output: { + success: true, + recordingSid: data.sid, + callSid: data.call_sid, + duration: data.duration ? Number.parseInt(data.duration, 10) : undefined, + status: data.status, + channels: data.channels, + source: data.source, + mediaUrl, + price: data.price, + priceUnit: data.price_unit, + uri: data.uri, + transcriptionText, + transcriptionStatus, + transcriptionPrice, + transcriptionPriceUnit, + }, + error: undefined, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the recording was successfully retrieved' }, + recordingSid: { type: 'string', description: 'Unique identifier for the recording' }, + callSid: { type: 'string', description: 'Call SID this recording belongs to' }, + duration: { type: 'number', description: 'Duration of the recording in seconds' }, + status: { type: 'string', description: 'Recording status (completed, processing, etc.)' }, + channels: { type: 'number', description: 'Number of channels (1 for mono, 2 for dual)' }, + source: { type: 'string', description: 'How the recording was created' }, + mediaUrl: { type: 'string', description: 'URL to download the recording media file' }, + price: { type: 'string', description: 'Cost of the recording' }, + priceUnit: { type: 'string', description: 'Currency of the price' }, + uri: { type: 'string', description: 'Relative URI of the recording resource' }, + transcriptionText: { + type: 'string', + description: 'Transcribed text from the recording (if available)', + }, + transcriptionStatus: { + type: 'string', + description: 'Transcription status (completed, in-progress, failed)', + }, + transcriptionPrice: { type: 'string', description: 'Cost of the transcription' }, + transcriptionPriceUnit: { type: 'string', description: 'Currency of the transcription price' }, + error: { type: 'string', description: 'Error message if retrieval failed' }, + }, +} diff --git a/apps/sim/tools/twilio_voice/index.ts b/apps/sim/tools/twilio_voice/index.ts new file mode 100644 index 0000000000..a2291c0404 --- /dev/null +++ b/apps/sim/tools/twilio_voice/index.ts @@ -0,0 +1,5 @@ +import { getRecordingTool } from '@/tools/twilio_voice/get_recording' +import { listCallsTool } from '@/tools/twilio_voice/list_calls' +import { makeCallTool } from '@/tools/twilio_voice/make_call' + +export { getRecordingTool, listCallsTool, makeCallTool } diff --git a/apps/sim/tools/twilio_voice/list_calls.ts b/apps/sim/tools/twilio_voice/list_calls.ts new file mode 100644 index 0000000000..a1ed2581ee --- /dev/null +++ b/apps/sim/tools/twilio_voice/list_calls.ts @@ -0,0 +1,182 @@ +import { createLogger } from '@/lib/logs/console/logger' +import type { TwilioListCallsOutput, TwilioListCallsParams } from '@/tools/twilio_voice/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('TwilioVoiceListCallsTool') + +export const listCallsTool: ToolConfig = { + id: 'twilio_voice_list_calls', + name: 'Twilio Voice List Calls', + description: 'Retrieve a list of calls made to and from an account.', + version: '1.0.0', + + params: { + accountSid: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Twilio Account SID', + }, + authToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Twilio Auth Token', + }, + to: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter by calls to this phone number', + }, + from: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter by calls from this phone number', + }, + status: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter by call status (queued, ringing, in-progress, completed, etc.)', + }, + startTimeAfter: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter calls that started on or after this date (YYYY-MM-DD)', + }, + startTimeBefore: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Filter calls that started on or before this date (YYYY-MM-DD)', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Number of records to return (max 1000, default 50)', + }, + }, + + request: { + url: (params) => { + if (!params.accountSid) { + throw new Error('Twilio Account SID is required') + } + if (!params.accountSid.startsWith('AC')) { + throw new Error( + `Invalid Account SID format. Account SID must start with "AC" (you provided: ${params.accountSid.substring(0, 2)}...)` + ) + } + + const baseUrl = `https://api.twilio.com/2010-04-01/Accounts/${params.accountSid}/Calls.json` + const queryParams = new URLSearchParams() + + if (params.to) queryParams.append('To', params.to) + if (params.from) queryParams.append('From', params.from) + if (params.status) queryParams.append('Status', params.status) + if (params.startTimeAfter) queryParams.append('StartTime>', params.startTimeAfter) + if (params.startTimeBefore) queryParams.append('StartTime<', params.startTimeBefore) + if (params.pageSize) queryParams.append('PageSize', params.pageSize.toString()) + + const queryString = queryParams.toString() + return queryString ? `${baseUrl}?${queryString}` : baseUrl + }, + method: 'GET', + headers: (params) => { + if (!params.accountSid || !params.authToken) { + throw new Error('Twilio credentials are required') + } + const authToken = Buffer.from(`${params.accountSid}:${params.authToken}`).toString('base64') + return { + Authorization: `Basic ${authToken}`, + } + }, + }, + + transformResponse: async (response, params) => { + const data = await response.json() + + logger.info('Twilio List Calls Response:', { total: data.calls?.length || 0 }) + + if (data.error_code) { + return { + success: false, + output: { + success: false, + calls: [], + error: data.message || data.error_message || 'Failed to retrieve calls', + }, + error: data.message || data.error_message || 'Failed to retrieve calls', + } + } + + const authToken = Buffer.from(`${params?.accountSid}:${params?.authToken}`).toString('base64') + + const calls = await Promise.all( + (data.calls || []).map(async (call: any) => { + let recordingSids: string[] = [] + if (call.subresource_uris?.recordings) { + try { + const recordingsUrl = `https://api.twilio.com${call.subresource_uris.recordings}` + const recordingsResponse = await fetch(recordingsUrl, { + method: 'GET', + headers: { Authorization: `Basic ${authToken}` }, + }) + + if (recordingsResponse.ok) { + const recordingsData = await recordingsResponse.json() + recordingSids = (recordingsData.recordings || []).map((rec: any) => rec.sid) + } + } catch (error) { + logger.warn(`Failed to fetch recordings for call ${call.sid}:`, error) + } + } + + return { + callSid: call.sid, + from: call.from, + to: call.to, + status: call.status, + direction: call.direction, + duration: call.duration ? Number.parseInt(call.duration, 10) : null, + price: call.price, + priceUnit: call.price_unit, + startTime: call.start_time, + endTime: call.end_time, + dateCreated: call.date_created, + recordingSids, + } + }) + ) + + logger.info('Transformed calls with recordings:', { + totalCalls: calls.length, + callsWithRecordings: calls.filter((c) => c.recordingSids.length > 0).length, + }) + + return { + success: true, + output: { + success: true, + calls, + total: calls.length, + page: data.page, + pageSize: data.page_size, + }, + error: undefined, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the calls were successfully retrieved' }, + calls: { type: 'array', description: 'Array of call objects' }, + total: { type: 'number', description: 'Total number of calls returned' }, + page: { type: 'number', description: 'Current page number' }, + pageSize: { type: 'number', description: 'Number of calls per page' }, + error: { type: 'string', description: 'Error message if retrieval failed' }, + }, +} diff --git a/apps/sim/tools/twilio_voice/make_call.ts b/apps/sim/tools/twilio_voice/make_call.ts new file mode 100644 index 0000000000..326dbfce06 --- /dev/null +++ b/apps/sim/tools/twilio_voice/make_call.ts @@ -0,0 +1,219 @@ +import { createLogger } from '@/lib/logs/console/logger' +import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' +import type { TwilioCallOutput, TwilioMakeCallParams } from '@/tools/twilio_voice/types' +import type { ToolConfig } from '@/tools/types' + +const logger = createLogger('TwilioVoiceMakeCallTool') + +export const makeCallTool: ToolConfig = { + id: 'twilio_voice_make_call', + name: 'Twilio Voice Make Call', + description: 'Make an outbound phone call using Twilio Voice API.', + version: '1.0.0', + + params: { + to: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Phone number to call (E.164 format, e.g., +14155551234)', + }, + from: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Twilio phone number to call from (E.164 format)', + }, + url: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'URL that returns TwiML instructions for the call', + }, + twiml: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: + 'TwiML instructions to execute (alternative to URL). Use square brackets instead of angle brackets, e.g., [Response][Say]Hello[/Say][/Response]', + }, + statusCallback: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Webhook URL for call status updates', + }, + statusCallbackMethod: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'HTTP method for status callback (GET or POST)', + }, + accountSid: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Twilio Account SID', + }, + authToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Twilio Auth Token', + }, + record: { + type: 'boolean', + required: false, + visibility: 'user-only', + description: 'Whether to record the call', + }, + recordingStatusCallback: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Webhook URL for recording status updates', + }, + timeout: { + type: 'number', + required: false, + visibility: 'user-only', + description: 'Time to wait for answer before giving up (seconds, default: 60)', + }, + machineDetection: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Answering machine detection: Enable or DetectMessageEnd', + }, + }, + + request: { + url: (params) => { + if (!params.accountSid) { + throw new Error('Twilio Account SID is required') + } + if (!params.accountSid.startsWith('AC')) { + throw new Error( + `Invalid Account SID format. Account SID must start with "AC" (you provided: ${params.accountSid.substring(0, 2)}...)` + ) + } + return `https://api.twilio.com/2010-04-01/Accounts/${params.accountSid}/Calls.json` + }, + method: 'POST', + headers: (params) => { + if (!params.accountSid || !params.authToken) { + throw new Error('Twilio credentials are required') + } + const authToken = Buffer.from(`${params.accountSid}:${params.authToken}`).toString('base64') + return { + Authorization: `Basic ${authToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + } + }, + body: ((params) => { + if (!params.to) { + throw new Error('Destination phone number (to) is required') + } + if (!params.from) { + throw new Error('Source phone number (from) is required') + } + if (!params.url && !params.twiml) { + throw new Error('Either URL or TwiML is required to execute the call') + } + + logger.info('Make call params:', { + to: params.to, + from: params.from, + record: params.record, + recordType: typeof params.record, + }) + + const formData = new URLSearchParams() + formData.append('To', params.to) + formData.append('From', params.from) + + if (params.url) { + formData.append('Url', params.url) + } else if (params.twiml) { + const convertedTwiml = convertSquareBracketsToTwiML(params.twiml) || params.twiml + formData.append('Twiml', convertedTwiml) + } + + if (params.statusCallback) { + formData.append('StatusCallback', params.statusCallback) + } + if (params.statusCallbackMethod) { + formData.append('StatusCallbackMethod', params.statusCallbackMethod) + } + + if (params.record === true) { + logger.info('Enabling call recording') + formData.append('Record', 'true') + } + + if (params.recordingStatusCallback) { + formData.append('RecordingStatusCallback', params.recordingStatusCallback) + } + if (params.timeout) { + formData.append('Timeout', params.timeout.toString()) + } + if (params.machineDetection) { + formData.append('MachineDetection', params.machineDetection) + } + + const bodyString = formData.toString() + logger.info('Final Twilio request body:', bodyString) + + return bodyString as any + }) as (params: TwilioMakeCallParams) => Record, + }, + + transformResponse: async (response) => { + const data = await response.json() + + logger.info('Twilio Make Call Response:', data) + + if (data.error_code || data.status === 'failed') { + return { + success: false, + output: { + success: false, + error: data.message || data.error_message || 'Call failed', + }, + error: data.message || data.error_message || 'Call failed', + } + } + + return { + success: true, + output: { + success: true, + callSid: data.sid, + status: data.status, + direction: data.direction, + from: data.from, + to: data.to, + duration: data.duration, + price: data.price, + priceUnit: data.price_unit, + }, + error: undefined, + } + }, + + outputs: { + success: { type: 'boolean', description: 'Whether the call was successfully initiated' }, + callSid: { type: 'string', description: 'Unique identifier for the call' }, + status: { + type: 'string', + description: 'Call status (queued, ringing, in-progress, completed, etc.)', + }, + direction: { type: 'string', description: 'Call direction (outbound-api)' }, + from: { type: 'string', description: 'Phone number the call is from' }, + to: { type: 'string', description: 'Phone number the call is to' }, + duration: { type: 'number', description: 'Call duration in seconds' }, + price: { type: 'string', description: 'Cost of the call' }, + priceUnit: { type: 'string', description: 'Currency of the price' }, + error: { type: 'string', description: 'Error message if call failed' }, + }, +} diff --git a/apps/sim/tools/twilio_voice/types.ts b/apps/sim/tools/twilio_voice/types.ts new file mode 100644 index 0000000000..5cf024bd54 --- /dev/null +++ b/apps/sim/tools/twilio_voice/types.ts @@ -0,0 +1,97 @@ +import type { ToolResponse } from '@/tools/types' + +export interface TwilioMakeCallParams { + to: string + from: string + url?: string + twiml?: string + statusCallback?: string + statusCallbackMethod?: 'GET' | 'POST' + statusCallbackEvent?: string[] + accountSid: string + authToken: string + record?: boolean + recordingStatusCallback?: string + recordingStatusCallbackMethod?: 'GET' | 'POST' + timeout?: number + machineDetection?: 'Enable' | 'DetectMessageEnd' + asyncAmd?: boolean + asyncAmdStatusCallback?: string +} + +export interface TwilioCallOutput extends ToolResponse { + output: { + success: boolean + callSid?: string + status?: string + direction?: string + from?: string + to?: string + duration?: number + price?: string + priceUnit?: string + error?: string + } +} + +export interface TwilioGetRecordingParams { + recordingSid: string + accountSid: string + authToken: string +} + +export interface TwilioGetRecordingOutput extends ToolResponse { + output: { + success: boolean + recordingSid?: string + callSid?: string + duration?: number + status?: string + channels?: number + source?: string + mediaUrl?: string + price?: string + priceUnit?: string + uri?: string + transcriptionText?: string + transcriptionStatus?: string + transcriptionPrice?: string + transcriptionPriceUnit?: string + error?: string + } +} + +export interface TwilioListCallsParams { + accountSid: string + authToken: string + to?: string + from?: string + status?: string + startTimeAfter?: string + startTimeBefore?: string + pageSize?: number +} + +export interface TwilioListCallsOutput extends ToolResponse { + output: { + success: boolean + calls?: Array<{ + callSid: string + from: string + to: string + status: string + direction: string + duration: number | null + price: string | null + priceUnit: string + startTime: string + endTime: string | null + dateCreated: string + recordingSids: string[] + }> + total?: number + page?: number + pageSize?: number + error?: string + } +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 6b88fdf9f8..7d9e45c6d6 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -11,6 +11,7 @@ import { outlookPollingTrigger } from '@/triggers/outlook' import { slackWebhookTrigger } from '@/triggers/slack' import { stripeWebhookTrigger } from '@/triggers/stripe' import { telegramWebhookTrigger } from '@/triggers/telegram' +import { twilioVoiceWebhookTrigger } from '@/triggers/twilio_voice' import type { TriggerRegistry } from '@/triggers/types' import { webflowCollectionItemChangedTrigger, @@ -33,6 +34,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { telegram_webhook: telegramWebhookTrigger, whatsapp_webhook: whatsappWebhookTrigger, google_forms_webhook: googleFormsWebhookTrigger, + twilio_voice_webhook: twilioVoiceWebhookTrigger, webflow_collection_item_created: webflowCollectionItemCreatedTrigger, webflow_collection_item_changed: webflowCollectionItemChangedTrigger, webflow_collection_item_deleted: webflowCollectionItemDeletedTrigger, diff --git a/apps/sim/triggers/twilio_voice/index.ts b/apps/sim/triggers/twilio_voice/index.ts new file mode 100644 index 0000000000..bccd3eb9b9 --- /dev/null +++ b/apps/sim/triggers/twilio_voice/index.ts @@ -0,0 +1 @@ +export { twilioVoiceWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/twilio_voice/webhook.ts b/apps/sim/triggers/twilio_voice/webhook.ts new file mode 100644 index 0000000000..8a13882880 --- /dev/null +++ b/apps/sim/triggers/twilio_voice/webhook.ts @@ -0,0 +1,254 @@ +import { TwilioIcon } from '@/components/icons' +import type { TriggerConfig } from '../types' + +export const twilioVoiceWebhookTrigger: TriggerConfig = { + id: 'twilio_voice_webhook', + name: 'Twilio Voice Webhook', + provider: 'twilio_voice', + description: 'Trigger workflow when phone calls are received via Twilio Voice', + version: '1.0.0', + icon: TwilioIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + }, + { + id: 'accountSid', + title: 'Twilio Account SID', + type: 'short-input', + placeholder: 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + description: 'Your Twilio Account SID from the Twilio Console', + required: true, + mode: 'trigger', + }, + { + id: 'authToken', + title: 'Auth Token', + type: 'short-input', + placeholder: 'Your Twilio Auth Token', + description: 'Your Twilio Auth Token for webhook signature verification', + password: true, + required: true, + mode: 'trigger', + }, + { + id: 'twimlResponse', + title: 'TwiML Response', + type: 'long-input', + placeholder: '[Response][Say]Please hold.[/Say][/Response]', + description: + 'TwiML instructions to return immediately to Twilio. Use square brackets instead of angle brackets (e.g., [Response] instead of ). This controls what happens when the call comes in (e.g., play a message, record, gather input). Your workflow will execute in the background.', + required: false, + mode: 'trigger', + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: [ + 'Enter a TwiML Response above - this tells Twilio what to do when a call comes in (e.g., play a message, record, gather input). Note: Use square brackets [Tag] instead of angle brackets for TwiML tags', + 'Example TwiML for recording with transcription: [Response][Say]Please leave a message.[/Say][Record transcribe="true" maxLength="120"/][/Response]', + 'Go to your Twilio Console Phone Numbers page at https://console.twilio.com/us1/develop/phone-numbers/manage/incoming', + 'Select the phone number you want to use for incoming calls.', + 'Scroll down to the "Voice Configuration" section.', + 'In the "A CALL COMES IN" field, select "Webhook" and paste the Webhook URL (from above).', + 'Ensure the HTTP method is set to POST.', + 'Click "Save configuration".', + 'How it works: When a call comes in, Twilio receives your TwiML response immediately and executes those instructions. Your workflow runs in the background with access to caller information, call status, and any recorded/transcribed data.', + ] + .map((instruction, index) => `${index + 1}. ${instruction}`) + .join('\n\n'), + mode: 'trigger', + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'twilio_voice_webhook', + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + { + CallSid: 'CA_NOT_A_REAL_SID', + AccountSid: 'AC_NOT_A_REAL_SID', + From: '+14155551234', + To: '+14155556789', + CallStatus: 'ringing', + ApiVersion: '2010-04-01', + Direction: 'inbound', + ForwardedFrom: '', + CallerName: 'John Doe', + FromCity: 'SAN FRANCISCO', + FromState: 'CA', + FromZip: '94105', + FromCountry: 'US', + ToCity: 'SAN FRANCISCO', + ToState: 'CA', + ToZip: '94105', + ToCountry: 'US', + }, + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + }, + ], + + outputs: { + callSid: { + type: 'string', + description: 'Unique identifier for this call', + }, + accountSid: { + type: 'string', + description: 'Twilio Account SID', + }, + from: { + type: 'string', + description: "Caller's phone number (E.164 format)", + }, + to: { + type: 'string', + description: 'Recipient phone number (your Twilio number)', + }, + callStatus: { + type: 'string', + description: 'Status of the call (queued, ringing, in-progress, completed, etc.)', + }, + direction: { + type: 'string', + description: 'Call direction: inbound or outbound', + }, + apiVersion: { + type: 'string', + description: 'Twilio API version', + }, + callerName: { + type: 'string', + description: 'Caller ID name if available', + }, + forwardedFrom: { + type: 'string', + description: 'Phone number that forwarded this call', + }, + digits: { + type: 'string', + description: 'DTMF digits entered by caller (from )', + }, + speechResult: { + type: 'string', + description: 'Speech recognition result (if using with speech)', + }, + recordingUrl: { + type: 'string', + description: 'URL of call recording if available', + }, + recordingSid: { + type: 'string', + description: 'Recording SID if available', + }, + called: { + type: 'string', + description: 'Phone number that was called (same as "to")', + }, + caller: { + type: 'string', + description: 'Phone number of the caller (same as "from")', + }, + toCity: { + type: 'string', + description: 'City of the called number', + }, + toState: { + type: 'string', + description: 'State/province of the called number', + }, + toZip: { + type: 'string', + description: 'Zip/postal code of the called number', + }, + toCountry: { + type: 'string', + description: 'Country of the called number', + }, + fromCity: { + type: 'string', + description: 'City of the caller', + }, + fromState: { + type: 'string', + description: 'State/province of the caller', + }, + fromZip: { + type: 'string', + description: 'Zip/postal code of the caller', + }, + fromCountry: { + type: 'string', + description: 'Country of the caller', + }, + calledCity: { + type: 'string', + description: 'City of the called number (same as toCity)', + }, + calledState: { + type: 'string', + description: 'State of the called number (same as toState)', + }, + calledZip: { + type: 'string', + description: 'Zip code of the called number (same as toZip)', + }, + calledCountry: { + type: 'string', + description: 'Country of the called number (same as toCountry)', + }, + callerCity: { + type: 'string', + description: 'City of the caller (same as fromCity)', + }, + callerState: { + type: 'string', + description: 'State of the caller (same as fromState)', + }, + callerZip: { + type: 'string', + description: 'Zip code of the caller (same as fromZip)', + }, + callerCountry: { + type: 'string', + description: 'Country of the caller (same as fromCountry)', + }, + callToken: { + type: 'string', + description: 'Twilio call token for authentication', + }, + raw: { + type: 'string', + description: 'Complete raw webhook payload from Twilio as JSON string', + }, + }, + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, +}