Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion integrations/twilio/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sentry as sentryHelpers } from '@botpress/sdk-addons'

export default new IntegrationDefinition({
name: 'twilio',
version: '0.4.6',
version: '0.5.0',
title: 'Twilio',
description: 'Send and receive messages, voice calls, emails, SMS, and more.',
icon: 'icon.svg',
Expand All @@ -13,6 +13,21 @@ export default new IntegrationDefinition({
schema: z.object({
accountSID: z.string().min(1),
authToken: z.string().min(1),
downloadMedia: z
.boolean()
.default(true)
.title('Download Media')
.describe(
'Automatically download media files using the Files API for content access. If disabled, temporary Twilio media URLs will be used, which require authentication.'
),
downloadedMediaExpiry: z
.number()
.default(24)
.optional()
.title('Downloaded Media Expiry')
.describe(
'Expiry time in hours for downloaded media files. An expiry time of 0 means the files will never expire.'
),
}),
},
channels: {
Expand Down
2 changes: 2 additions & 0 deletions integrations/twilio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
"private": true,
"scripts": {
"build": "bp add -y && bp build",
"deploy": "bp deploy",
"check:type": "tsc --noEmit",
"check:bplint": "bp lint"
},
"dependencies": {
"@botpress/client": "workspace:*",
"@botpress/sdk": "workspace:*",
"@botpress/sdk-addons": "workspace:*",
"axios": "^1.6.0",
"query-string": "^6.14.1",
"twilio": "^3.84.0"
},
Expand Down
220 changes: 210 additions & 10 deletions integrations/twilio/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { RuntimeError } from '@botpress/client'
import { sentry as sentryHelpers } from '@botpress/sdk-addons'
import axios from 'axios'
import * as crypto from 'crypto'
import queryString from 'query-string'
import { Twilio } from 'twilio'
import * as types from './types'
Expand Down Expand Up @@ -53,7 +55,7 @@ const integration = new bp.Integration({
},
},
},
handler: async ({ req, client }) => {
handler: async ({ req, client, ctx, logger }) => {
console.info('Handler received request')

if (!req.body) {
Expand All @@ -63,6 +65,15 @@ const integration = new bp.Integration({

const data = queryString.parse(req.body)

// Twilio webhook data structure for media messages:
// - NumMedia: Number of media files (e.g., "1", "2")
// - MediaUrl0, MediaUrl1, etc.: URLs to the media files
// - MediaContentType0, MediaContentType1, etc.: MIME types of the media files
// - Body: Text message (may be empty if only media is sent)
// - From: Sender's phone number
// - To: Recipient's phone number (your Twilio number)
// - MessageSid: Unique identifier for the message

const userPhone = data.From

if (typeof userPhone !== 'string') {
Expand Down Expand Up @@ -96,18 +107,55 @@ const integration = new bp.Integration({
}

const text = data.Body
const numMedia = parseInt((data.NumMedia as string) || '0', 10)

// If there's media, create appropriate message types for each media
if (numMedia > 0) {
for (let i = 0; i < numMedia; i++) {
const mediaUrl = data[`MediaUrl${i}` as keyof typeof data]

// Guard condition: skip invalid media URLs
if (!mediaUrl || typeof mediaUrl !== 'string') {
logger.forBot().error(`Missing or invalid media URL for media ${i + 1}`)
continue
}

try {
// Get media metadata first
const metadata = await getTwilioMediaMetadata(mediaUrl, ctx)

// Download media if configuration is enabled, otherwise use original URL
let finalMediaUrl = mediaUrl
if (ctx.configuration.downloadMedia) {
finalMediaUrl = await _downloadTwilioMedia(mediaUrl, client, ctx)
}

if (typeof text !== 'string') {
throw new Error('Handler received an invalid text')
// Determine message type based on MIME type and create payload
const { messageType, payload } = getMessageTypeAndPayload(finalMediaUrl, metadata.mimeType, metadata.fileName)

await client.createMessage({
tags: { id: `${messageSid}_media_${i}` },
type: messageType,
userId: user.id,
conversationId: conversation.id,
payload,
})
} catch (error) {
logger.forBot().error(`Failed to create message for media ${i + 1}:`, error)
}
}
}

await client.createMessage({
tags: { id: messageSid },
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text },
})
// Create text message if text is present (regardless of whether media was also sent)
if (typeof text === 'string' && text.trim()) {
await client.createMessage({
tags: { id: messageSid },
type: 'text',
userId: user.id,
conversationId: conversation.id,
payload: { text },
})
}

console.info('Handler received request', data)
},
Expand Down Expand Up @@ -197,6 +245,158 @@ type SendMessageProps = Pick<MessageHandlerProps, 'ctx' | 'conversation' | 'ack'
text?: string
}

/**
* Gets Twilio media metadata (MIME type, file size, filename)
*/
async function getTwilioMediaMetadata(
mediaUrl: string,
ctx: bp.Context
): Promise<{ mimeType: string; fileSize: number; fileName?: string }> {
try {
const headResponse = await axios.head(mediaUrl, {
auth: {
username: ctx.configuration.accountSID,
password: ctx.configuration.authToken,
},
})

const mimeType = headResponse.headers['content-type'] || 'application/octet-stream'
const fileSize = parseInt(headResponse.headers['content-length'] || '0', 10)

// Try to extract filename from content-disposition header
let fileName: string | undefined
const contentDisposition = headResponse.headers['content-disposition']
if (contentDisposition) {
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^"]+)"?/i)
const rawFileName = match?.[1]
if (rawFileName) {
fileName = decodeURIComponent(rawFileName)
}
}

return {
mimeType,
fileSize,
fileName,
}
} catch (error) {
throw new RuntimeError(
`Failed to get Twilio media metadata: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}

/**
* Downloads Twilio media and stores it in the Botpress file API
*/
async function _downloadTwilioMedia(mediaUrl: string, client: bp.Client, ctx: bp.Context): Promise<string> {
try {
// Get file metadata
const { mimeType, fileSize } = await getTwilioMediaMetadata(mediaUrl, ctx)

// Generate a unique ID from the URL
const buffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(mediaUrl))
const uniqueId = Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 24)

// Create file in Botpress file API
const { file } = await client.upsertFile({
key: `twilio-media_${uniqueId}`,
expiresAt: _getMediaExpiry(ctx),
contentType: mimeType,
accessPolicies: ['public_content'],
publicContentImmediatelyAccessible: true,
size: fileSize,
tags: {
source: 'integration',
integration: 'twilio',
channel: 'channel',
originUrl: mediaUrl,
},
})

// Download the media from Twilio
const downloadResponse = await axios.get(mediaUrl, {
responseType: 'stream',
auth: {
username: ctx.configuration.accountSID,
password: ctx.configuration.authToken,
},
})

// Upload to Botpress file API
await axios.put(file.uploadUrl, downloadResponse.data, {
headers: {
'Content-Type': mimeType,
'Content-Length': fileSize,
'x-amz-tagging': 'public=true',
},
maxBodyLength: fileSize,
})

return file.url
} catch (error) {
throw new RuntimeError(
`Failed to download Twilio media: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}

/**
* Gets media expiry time based on configuration
*/
function _getMediaExpiry(ctx: bp.Context): string | undefined {
const expiryDelayHours = (ctx.configuration as any).downloadedMediaExpiry || 0
if (expiryDelayHours === 0) {
return undefined
}
const expiresAt = new Date(Date.now() + expiryDelayHours * 60 * 60 * 1000)
return expiresAt.toISOString()
}

/**
* Determines the appropriate message type and payload based on MIME type
*/
function getMessageTypeAndPayload(
mediaUrl: string,
contentType: string | null | undefined,
fileName?: string
): { messageType: 'image' | 'audio' | 'video' | 'file'; payload: any } {
const mimeType = contentType?.toLowerCase() || ''

if (mimeType.startsWith('image/')) {
return {
messageType: 'image',
payload: { imageUrl: mediaUrl },
}
}

if (mimeType.startsWith('audio/')) {
return {
messageType: 'audio',
payload: { audioUrl: mediaUrl },
}
}

if (mimeType.startsWith('video/')) {
return {
messageType: 'video',
payload: { videoUrl: mediaUrl },
}
}

// Default to file for other types (documents, etc.)
return {
messageType: 'file',
payload: {
fileUrl: mediaUrl,
title: fileName || contentType || 'file',
},
}
}

async function sendMessage({ ctx, conversation, ack, mediaUrl, text }: SendMessageProps) {
const twilioClient = new Twilio(ctx.configuration.accountSID, ctx.configuration.authToken)
const { to, from } = getPhoneNumbers(conversation)
Expand Down
Loading
Loading