Files
botpress--botpress/integrations/twilio/src/utils.ts
T
2026-07-13 13:34:48 +08:00

211 lines
5.7 KiB
TypeScript

import { RuntimeError } from '@botpress/client'
import axios from 'axios'
import * as crypto from 'crypto'
import { TwilioChannel } from './twilio'
import { Card, Choice, Conversation, CreateMessageInputPayload, CreateMessageInputType } from './types'
import * as bp from '.botpress'
/**
* Gets phone numbers from conversation tags
*/
export function getPhoneNumbers(conversation: Conversation) {
const to = conversation.tags?.userPhone
const from = conversation.tags?.activePhone
if (!to) {
throw new Error('Invalid to phone number')
}
if (!from) {
throw new Error('Invalid from phone number')
}
return { to, from }
}
/**
* Determines the Twilio channel type based on user phone format
*/
export function getTwilioChannelType(user: string): TwilioChannel {
if (user.startsWith('whatsapp')) {
return 'whatsapp'
}
if (user.startsWith('messenger')) {
return 'messenger'
}
if (user.startsWith('rcs')) {
return 'rcs'
}
return 'sms/mms'
}
/**
* Gets Twilio media metadata (MIME type, file size, filename)
*/
export 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
*/
export 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
*/
export function getMediaExpiry(ctx: bp.Context): string | undefined {
const expiryDelayHours = ctx.configuration?.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
*/
export function getMessageTypeAndPayload(
mediaUrl: string,
contentType: string | null | undefined,
fileName?: string
): { messageType: CreateMessageInputType; payload: CreateMessageInputPayload } {
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',
},
}
}
/**
* Renders choice/dropdown message as text for SMS
*/
export function renderChoiceMessage(payload: Choice): string {
return `${payload.text || ''}\n\n${payload.options
.map(({ label }: { label: string }, idx: number) => `${idx + 1}. ${label}`)
.join('\n')}`
}
/**
* Renders card as text for SMS
*/
export function renderCard(card: Card, total?: string): string {
return `${total ? `${total}: ` : ''}${card.title}\n\n${card.subtitle || ''}\n\n${card.actions
.map(({ label }: { label: string }, idx: number) => `${idx + 1}. ${label}`)
.join('\n')}`
}