feat(twilio-voice): added twilio voice webhook + tool (#1776)

* first twilio voice push, no testing

* simplified operations, calling and get recording works

* cleaned up operations

* twilio webhook works

* added docs

* updated logic

* minor change

* reverted change

* files fixed for bun run lint

* fix instructions

* removed unused files

* added slack validation and twilio

* changed twiml instruction

* fixed build

* cleanup

* remove extraneous comments

* fixed missing mcosk

* split out client-side utils

* update docs

* fix tests

* had to add some reverts

* custom tool that was fialing bulid

* reveted changes
This commit is contained in:
Adam Gough
2025-11-04 23:56:06 -08:00
committed by GitHub
parent b0fa3e8a26
commit fa323e2e51
26 changed files with 3612 additions and 1936 deletions

View File

@@ -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 |

View File

@@ -63,6 +63,7 @@
"thinking",
"translate",
"twilio_sms",
"twilio_voice",
"typeform",
"vision",
"wealthbox",

View File

@@ -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 `<at>` tags: `<at>userName</at>`
@@ -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 `<at>` tags:
```
<at>John Doe</at> 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.

View File

@@ -0,0 +1,146 @@
---
title: Twilio Voice
description: Make and manage phone calls
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="twilio_voice"
color="#F22F46"
icon={true}
iconSvg={`<svg className="block-icon" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'>
<path
fill='currentColor'
d='M128 0c70.656 0 128 57.344 128 128s-57.344 128-128 128S0 198.656 0 128 57.344 0 128 0zm0 33.792c-52.224 0-94.208 41.984-94.208 94.208S75.776 222.208 128 222.208s94.208-41.984 94.208-94.208S180.224 33.792 128 33.792zm31.744 99.328c14.704 0 26.624 11.92 26.624 26.624 0 14.704-11.92 26.624-26.624 26.624-14.704 0-26.624-11.92-26.624-26.624 0-14.704 11.92-26.624 26.624-26.624zm-63.488 0c14.704 0 26.624 11.92 26.624 26.624 0 14.704-11.92 26.624-26.624 26.624-14.704 0-26.624-11.92-26.624-26.624 0-14.704 11.92-26.624 26.624-26.624zm63.488-63.488c14.704 0 26.624 11.92 26.624 26.624 0 14.704-11.92 26.624-26.624 26.624-14.704 0-26.624-11.92-26.624-26.624 0-14.704 11.92-26.624 26.624-26.624zm-63.488 0c14.704 0 26.624 11.92 26.624 26.624 0 14.704-11.92 26.624-26.624 26.624-14.704 0-26.624-11.92-26.624-26.624 0-14.704 11.92-26.624 26.624-26.624z'
/>
</svg>`}
/>
{/* 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`

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 || {},
[]
)

View File

@@ -10,7 +10,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
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 <at> tags: <at>userName</at>',
'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 `<at>` tags: `<at>userName</at>`',
docsLink: 'https://docs.sim.ai/tools/microsoft_teams',
category: 'tools',
triggerAllowed: true,

View File

@@ -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<ToolResponse> = {
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 <Response>)',
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 <Record transcribe="true">)',
},
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 <Gather>)' },
speechResult: { type: 'string', description: 'Speech recognition result (if using <Gather>)' },
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'],
},
}

View File

@@ -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<string, BlockConfig> = {
thinking: ThinkingBlock,
translate: TranslateBlock,
twilio_sms: TwilioSMSBlock,
twilio_voice: TwilioVoiceBlock,
typeform: TypeformBlock,
variables: VariablesBlock,
vision: VisionBlock,

View File

@@ -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', () => ({

View File

@@ -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, string>): 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<string, any>,
envVars: Record<string, string>
): Record<string, any> {
const resolved: Record<string, any> = {}
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<NextResponse | null> {
if (foundWebhook.provider === 'microsoftteams') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
// Step 1: Fetch and decrypt environment variables for signature verification
let decryptedEnvVars: Record<string, string> = {}
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<string, any>) || {}
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<string, any>) || {}
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<string, any>) || {}
// 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<string, any> = {}
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 <token> (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<string, any>) || {}
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 = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Your call is being processed.</Say>
<Pause length="1"/>
</Response>`
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 = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>We're sorry, but an error occurred processing your call. Please try again later.</Say>
<Hangup/>
</Response>`
return new NextResponse(errorTwiml, {
status: 200,
headers: {
'Content-Type': 'text/xml',
},
})
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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', () => ({

View File

@@ -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) {

View File

@@ -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<string, ToolConfig> = {
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,

View File

@@ -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<TwilioGetRecordingParams, TwilioGetRecordingOutput> = {
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 <Record transcribe="true"> 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' },
},
}

View File

@@ -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 }

View File

@@ -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<TwilioListCallsParams, TwilioListCallsOutput> = {
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' },
},
}

View File

@@ -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<TwilioMakeCallParams, TwilioCallOutput> = {
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<string, any>,
},
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' },
},
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -0,0 +1 @@
export { twilioVoiceWebhookTrigger } from './webhook'

View File

@@ -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 <Response>). 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 <Gather>)',
},
speechResult: {
type: 'string',
description: 'Speech recognition result (if using <Gather> 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',
},
},
}