chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.botpress
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IntegrationDefinitionProps, messages } from '@botpress/sdk'
|
||||
|
||||
export const channels = {
|
||||
channel: {
|
||||
title: 'Conversation Channel',
|
||||
description: 'A channel for sending and receiving messages through Twilio Conversations',
|
||||
messages: { ...messages.defaults, bloc: messages.markdownBloc },
|
||||
message: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Message ID',
|
||||
description: 'The Twilio message ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
conversation: {
|
||||
tags: {
|
||||
userPhone: {
|
||||
title: 'User Phone',
|
||||
description: 'The phone number of the user',
|
||||
},
|
||||
activePhone: {
|
||||
title: 'Active Phone',
|
||||
description: 'The phone number of the active user',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['channels']
|
||||
@@ -0,0 +1,23 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const configuration = {
|
||||
schema: z.object({
|
||||
accountSID: z.string().min(1).describe('The account SID').title('Account SID'),
|
||||
authToken: z.string().min(1).describe('The token for authentication').title('Authorization token'),
|
||||
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.'
|
||||
),
|
||||
}),
|
||||
} satisfies IntegrationDefinitionProps['configuration']
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const entities = {
|
||||
user: {
|
||||
title: 'User',
|
||||
description: 'A Twilio user',
|
||||
schema: z
|
||||
.object({
|
||||
userPhone: z.string().describe('The phone number of the user').title('User Phone Number'),
|
||||
})
|
||||
.title('User')
|
||||
.describe('The user object fields'),
|
||||
},
|
||||
conversation: {
|
||||
title: 'Conversation',
|
||||
description: 'A Twilio conversation',
|
||||
schema: z
|
||||
.object({
|
||||
userPhone: z.string().describe('The phone number of the user').title('User Phone Number'),
|
||||
activePhone: z.string().describe('The Phone number the message was sent from').title('Active Phone Number'),
|
||||
})
|
||||
.title('Conversation')
|
||||
.describe('The conversation object fields'),
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['entities']
|
||||
@@ -0,0 +1,4 @@
|
||||
export { channels } from './channels'
|
||||
export { configuration } from './configuration'
|
||||
export { entities } from './entities'
|
||||
export { user } from './user'
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const user = {
|
||||
tags: {
|
||||
userPhone: {
|
||||
title: 'User Phone',
|
||||
description: 'The phone number of the user',
|
||||
},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['user']
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Twilio integration
|
||||
|
||||
## Migrating from version 0.x.x to 1.0.0
|
||||
|
||||
- You can now proactively send a message to a user with the startConversation action.
|
||||
- You can now use the action card GetOrCreateUser to get or create a user.
|
||||
- The markdown message type is replaced by markdown support in all texts.
|
||||
|
||||
## New in version 1.3.0
|
||||
|
||||
- Typing/Read indicators are now sent for WhatsApp conversations.
|
||||
|
||||
## Description
|
||||
|
||||
The Twilio integration enables seamless communication between your AI-powered chatbot and Twilio, a powerful cloud communications platform. Connect your chatbot to Twilio and leverage a wide range of communication channels, including SMS, voice calls, and messaging apps. With this integration, you can automate interactions, send personalized notifications, handle inquiries, and provide customer support directly through Twilio. Utilize Twilio's extensive features such as SMS delivery, call routing, message templates, and more to create robust and efficient conversational experiences. Enhance your communication strategy and reach customers effectively with the Twilio Integration for Botpress.
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="2500" height="2500" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><g fill="#CF272D"><path d="M127.86 222.304c-52.005 0-94.164-42.159-94.164-94.163 0-52.005 42.159-94.163 94.164-94.163 52.004 0 94.162 42.158 94.162 94.163 0 52.004-42.158 94.163-94.162 94.163zm0-222.023C57.245.281 0 57.527 0 128.141 0 198.756 57.245 256 127.86 256c70.614 0 127.859-57.244 127.859-127.859 0-70.614-57.245-127.86-127.86-127.86z"/><path d="M133.116 96.297c0-14.682 11.903-26.585 26.586-26.585 14.683 0 26.585 11.903 26.585 26.585 0 14.684-11.902 26.586-26.585 26.586-14.683 0-26.586-11.902-26.586-26.586M133.116 159.983c0-14.682 11.903-26.586 26.586-26.586 14.683 0 26.585 11.904 26.585 26.586 0 14.683-11.902 26.586-26.585 26.586-14.683 0-26.586-11.903-26.586-26.586M69.431 159.983c0-14.682 11.904-26.586 26.586-26.586 14.683 0 26.586 11.904 26.586 26.586 0 14.683-11.903 26.586-26.586 26.586-14.682 0-26.586-11.903-26.586-26.586M69.431 96.298c0-14.683 11.904-26.585 26.586-26.585 14.683 0 26.586 11.902 26.586 26.585 0 14.684-11.903 26.586-26.586 26.586-14.682 0-26.586-11.902-26.586-26.586"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,56 @@
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
import proactiveConversation from 'bp_modules/proactive-conversation'
|
||||
import proactiveUser from 'bp_modules/proactive-user'
|
||||
import typingIndicator from 'bp_modules/typing-indicator'
|
||||
import { channels, configuration, entities, user } from './definitions'
|
||||
|
||||
export const INTEGRATION_NAME = 'twilio'
|
||||
export const INTEGRATION_VERSION = '1.3.3'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: INTEGRATION_VERSION,
|
||||
title: 'Twilio',
|
||||
description: 'Send and receive messages, voice calls, emails, SMS, and more.',
|
||||
icon: 'icon.svg',
|
||||
readme: 'hub.md',
|
||||
configuration,
|
||||
channels,
|
||||
user,
|
||||
entities,
|
||||
actions: {},
|
||||
events: {},
|
||||
secrets: {
|
||||
POSTHOG_KEY: {
|
||||
description: 'Posthog key for error dashboards',
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Communication & Channels',
|
||||
guideSlug: 'twilio',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
.extend(typingIndicator, () => ({ entities: {} }))
|
||||
.extend(proactiveConversation, ({ entities }) => ({
|
||||
entities: {
|
||||
conversation: entities.conversation,
|
||||
},
|
||||
actions: {
|
||||
getOrCreateConversation: {
|
||||
name: 'startConversation',
|
||||
title: 'Start proactive conversation',
|
||||
description: 'Start a proactive conversation given a user',
|
||||
},
|
||||
},
|
||||
}))
|
||||
.extend(proactiveUser, ({ entities }) => ({
|
||||
entities: { user: entities.user },
|
||||
actions: {
|
||||
getOrCreateUser: {
|
||||
name: 'getOrCreateUser',
|
||||
title: 'Get or create user',
|
||||
description: 'Get or create a user in the Twilio channel',
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@botpresshub/twilio",
|
||||
"description": "Twilio integration for Botpress",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"deploy": "bp deploy",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpress/sdk-addons": "workspace:*",
|
||||
"axios": "^1.6.0",
|
||||
"query-string": "^6.14.1",
|
||||
"twilio": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/proactive-conversation": "workspace:*",
|
||||
"@botpresshub/proactive-user": "workspace:*",
|
||||
"@botpresshub/typing-indicator": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"proactive-conversation": "../../interfaces/proactive-conversation",
|
||||
"proactive-user": "../../interfaces/proactive-user",
|
||||
"typing-indicator": "../../interfaces/typing-indicator"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as bp from '../../.botpress'
|
||||
import { getTwilioClient } from '../twilio'
|
||||
|
||||
export const getOrCreateUser: bp.IntegrationProps['actions']['getOrCreateUser'] = async ({ client, ctx, input }) => {
|
||||
const userPhone = input.user.userPhone
|
||||
if (!userPhone) {
|
||||
throw new sdk.RuntimeError('Could not create a user: missing channel or userId')
|
||||
}
|
||||
|
||||
const twilioClient = getTwilioClient(ctx)
|
||||
const phone = await twilioClient.lookups.phoneNumbers(userPhone).fetch()
|
||||
|
||||
const { user } = await client.getOrCreateUser({ tags: { userPhone: phone.phoneNumber } })
|
||||
|
||||
return { userId: user.id }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as bp from '../../.botpress'
|
||||
import { getOrCreateUser } from './get-or-create-user'
|
||||
import { startConversation } from './start-conversation'
|
||||
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
|
||||
|
||||
export const actions = {
|
||||
getOrCreateUser,
|
||||
startConversation,
|
||||
startTypingIndicator,
|
||||
stopTypingIndicator,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as bp from '../../.botpress'
|
||||
import { getTwilioClient } from '../twilio'
|
||||
|
||||
export const startConversation: bp.IntegrationProps['actions']['startConversation'] = async ({
|
||||
client,
|
||||
ctx,
|
||||
input,
|
||||
}) => {
|
||||
const userPhone = input.conversation.userPhone
|
||||
const activePhone = input.conversation.activePhone
|
||||
|
||||
if (!activePhone || !activePhone) {
|
||||
throw new sdk.RuntimeError('Could not create conversation: missing channel, channelId or userId')
|
||||
}
|
||||
|
||||
const twilioClient = getTwilioClient(ctx)
|
||||
const phone = await twilioClient.lookups.phoneNumbers(userPhone).fetch()
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
tags: { activePhone, userPhone: phone.phoneNumber },
|
||||
channel: 'channel',
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as bp from '../../.botpress'
|
||||
import { getTwilioClient } from '../twilio'
|
||||
import { getPhoneNumbers, getTwilioChannelType } from '../utils'
|
||||
|
||||
async function sendTypingIndicator({
|
||||
client,
|
||||
ctx,
|
||||
conversationId,
|
||||
messageId,
|
||||
}: {
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
conversationId: string
|
||||
messageId: string
|
||||
}): Promise<void> {
|
||||
const { conversation } = await client.getConversation({ id: conversationId })
|
||||
const { to } = getPhoneNumbers(conversation)
|
||||
const channelType = getTwilioChannelType(to)
|
||||
|
||||
// Typing indicators only supported for WhatsApp
|
||||
if (channelType !== 'whatsapp') {
|
||||
return
|
||||
}
|
||||
|
||||
const { message } = await client.getMessage({ id: messageId })
|
||||
const twilioMessageSid = message.tags?.id
|
||||
|
||||
if (!twilioMessageSid) {
|
||||
return
|
||||
}
|
||||
|
||||
const twilioClient = getTwilioClient(ctx)
|
||||
|
||||
// The Twilio SDK doesn't expose the v2 Indicators API natively yet (Its in beta),
|
||||
// so we use client.request() to call the REST endpoint directly
|
||||
await twilioClient.request({
|
||||
method: 'post',
|
||||
uri: 'https://messaging.twilio.com/v2/Indicators/Typing.json',
|
||||
data: {
|
||||
messageId: twilioMessageSid,
|
||||
channel: 'whatsapp',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const startTypingIndicator: bp.IntegrationProps['actions']['startTypingIndicator'] = async ({
|
||||
client,
|
||||
ctx,
|
||||
input,
|
||||
logger,
|
||||
}) => {
|
||||
try {
|
||||
const { conversationId, messageId } = input
|
||||
await sendTypingIndicator({ client, ctx, conversationId, messageId })
|
||||
} catch (error) {
|
||||
const thrown = error instanceof Error ? error : new Error(String(error))
|
||||
logger.forBot().warn(`Failed to send typing indicator: ${thrown.message ?? '[Unknown error]'}`)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export const stopTypingIndicator: bp.IntegrationProps['actions']['stopTypingIndicator'] = async () => {
|
||||
// Twilio's typing indicator automatically stops after 25 seconds or when a message is sent.
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { isApiError } from '@botpress/client'
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
|
||||
import { transformMarkdownForTwilio } from './markdown-to-twilio'
|
||||
import { getTwilioClient } from './twilio'
|
||||
import { getPhoneNumbers, getTwilioChannelType, renderCard, renderChoiceMessage } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type Channels = bp.Integration['channels']
|
||||
type Messages = Channels[keyof Channels]['messages']
|
||||
type MessageHandler = Messages[keyof Messages]
|
||||
type MessageHandlerProps = Parameters<MessageHandler>[0]
|
||||
|
||||
type SendMessageProps = Pick<MessageHandlerProps, 'ctx' | 'conversation' | 'ack' | 'logger'> & {
|
||||
mediaUrl?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
function renderLocation({
|
||||
title,
|
||||
address,
|
||||
latitude,
|
||||
longitude,
|
||||
}: {
|
||||
latitude: number
|
||||
longitude: number
|
||||
title?: string
|
||||
address?: string
|
||||
}): string {
|
||||
const messageParts: string[] = []
|
||||
|
||||
if (title) {
|
||||
messageParts.push(title, '')
|
||||
}
|
||||
if (address) {
|
||||
messageParts.push(address, '')
|
||||
}
|
||||
messageParts.push(`https://www.google.com/maps/search/?api=1&query=${latitude},${longitude}`)
|
||||
|
||||
return messageParts.join('\n')
|
||||
}
|
||||
|
||||
async function sendMessage({ ctx, conversation, ack, mediaUrl, text, logger }: SendMessageProps) {
|
||||
const twilioClient = getTwilioClient(ctx)
|
||||
const { to, from } = getPhoneNumbers(conversation)
|
||||
const twilioChannel = getTwilioChannelType(to)
|
||||
let body = text
|
||||
if (body !== undefined) {
|
||||
try {
|
||||
body = transformMarkdownForTwilio(body, twilioChannel)
|
||||
} catch (thrown) {
|
||||
const errMsg = thrown instanceof Error ? thrown.message : String(thrown)
|
||||
logger.forBot().debug('Failed to transform markdown - Error:', errMsg)
|
||||
const distinctId = isApiError(thrown) ? thrown.id : undefined
|
||||
await posthogHelper.sendPosthogEvent(
|
||||
{
|
||||
distinctId: distinctId ?? 'no id',
|
||||
event: 'unhandled_markdown',
|
||||
properties: { errMsg },
|
||||
},
|
||||
{ integrationName: INTEGRATION_NAME, integrationVersion: INTEGRATION_VERSION, key: bp.secrets.POSTHOG_KEY }
|
||||
)
|
||||
}
|
||||
}
|
||||
const { sid } = await twilioClient.messages.create({ to, from, mediaUrl: mediaUrl ? [mediaUrl] : undefined, body })
|
||||
await ack({ tags: { id: sid } })
|
||||
}
|
||||
|
||||
export const channels = {
|
||||
channel: {
|
||||
messages: {
|
||||
text: async (props) => void (await sendMessage({ ...props, text: props.payload.text })),
|
||||
image: async (props) => void (await sendMessage({ ...props, mediaUrl: props.payload.imageUrl })),
|
||||
audio: async (props) => void (await sendMessage({ ...props, mediaUrl: props.payload.audioUrl })),
|
||||
video: async (props) => void (await sendMessage({ ...props, mediaUrl: props.payload.videoUrl })),
|
||||
file: async (props) => void (await sendMessage({ ...props, text: props.payload.fileUrl })),
|
||||
location: async (props) => void (await sendMessage({ ...props, text: renderLocation(props.payload) })),
|
||||
carousel: async (props) => {
|
||||
const {
|
||||
payload: { items },
|
||||
} = props
|
||||
const total = items.length
|
||||
for (const [i, card] of items.entries()) {
|
||||
await sendMessage({ ...props, text: renderCard(card, `${i + 1}/${total}`), mediaUrl: card.imageUrl })
|
||||
}
|
||||
},
|
||||
card: async (props) => {
|
||||
const { payload: card } = props
|
||||
await sendMessage({ ...props, text: renderCard(card), mediaUrl: card.imageUrl })
|
||||
},
|
||||
dropdown: async (props) => {
|
||||
await sendMessage({ ...props, text: renderChoiceMessage(props.payload) })
|
||||
},
|
||||
choice: async (props) => {
|
||||
await sendMessage({ ...props, text: renderChoiceMessage(props.payload) })
|
||||
},
|
||||
bloc: async (props) => {
|
||||
for (const item of props.payload.items) {
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
await sendMessage({ ...props, text: item.payload.text })
|
||||
break
|
||||
case 'markdown':
|
||||
await sendMessage({ ...props, text: item.payload.markdown })
|
||||
break
|
||||
case 'image':
|
||||
await sendMessage({ ...props, mediaUrl: item.payload.imageUrl })
|
||||
break
|
||||
case 'video':
|
||||
await sendMessage({ ...props, mediaUrl: item.payload.videoUrl })
|
||||
break
|
||||
case 'audio':
|
||||
await sendMessage({ ...props, mediaUrl: item.payload.audioUrl })
|
||||
break
|
||||
case 'file':
|
||||
await sendMessage({ ...props, text: item.payload.fileUrl })
|
||||
break
|
||||
case 'location':
|
||||
await sendMessage({ ...props, text: renderLocation(item.payload) })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies bp.IntegrationProps['channels']
|
||||
@@ -0,0 +1,104 @@
|
||||
import queryString from 'query-string'
|
||||
import { downloadTwilioMedia, getMessageTypeAndPayload, getTwilioMediaMetadata } from './utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async ({ req, client, ctx, logger }) => {
|
||||
if (!req.body) {
|
||||
console.warn('Handler received an empty body')
|
||||
return
|
||||
}
|
||||
|
||||
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') {
|
||||
throw new Error('Handler received an invalid user phone number')
|
||||
}
|
||||
|
||||
const activePhone = data.To
|
||||
|
||||
if (typeof activePhone !== 'string') {
|
||||
throw new Error('Handler received an invalid active phone number')
|
||||
}
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: {
|
||||
userPhone,
|
||||
activePhone,
|
||||
},
|
||||
})
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: {
|
||||
userPhone,
|
||||
},
|
||||
})
|
||||
|
||||
const messageSid = data.MessageSid
|
||||
|
||||
if (typeof messageSid !== 'string') {
|
||||
throw new Error('Handler received an invalid message sid')
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { reporting } from '@botpress/sdk-addons'
|
||||
import { actions } from './actions'
|
||||
import { channels } from './channels'
|
||||
import { handler } from './handler'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const integration = new bp.Integration({
|
||||
register: async () => {},
|
||||
unregister: async () => {},
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
})
|
||||
|
||||
export default reporting.wrapIntegration(integration)
|
||||
@@ -0,0 +1,171 @@
|
||||
import { test, expect } from 'vitest'
|
||||
import { transformMarkdownForTwilio } from './markdown-to-twilio'
|
||||
|
||||
const FIXED_SIZE_SPACE_CHAR = '\u2002' // 'En space' yields better results for identation in WhatsApp messages
|
||||
|
||||
type Test = Record<string, { input: string; expected: string }>
|
||||
|
||||
const messengerTests: Test = {
|
||||
Code: { input: '`code`', expected: '`code`\n' },
|
||||
Code_snippet: { input: '```\n{\n\ta: null\n}\n```', expected: '```\n{\n\ta: null\n}\n```\n' },
|
||||
Strong_with_asterisk: { input: '**bold-asterisk**', expected: '*bold-asterisk*\n' },
|
||||
Strong_with_underscore: { input: '__bold-underscore__', expected: '*bold-underscore*\n' },
|
||||
Emphasis_with_asterisk: { input: '*italic-asterisk*', expected: '_italic-asterisk_\n' },
|
||||
Emphasis_with_underscore: { input: '_italic-underscore_', expected: '_italic-underscore_\n' },
|
||||
Strong_emphasis: { input: '**_strong-emphasis_**', expected: '*_strong-emphasis_*\n' },
|
||||
Strong_delete: { input: '**~strong-delete~**', expected: '*~strong-delete~*\n' },
|
||||
Emphasis_delete: { input: '_~emphasis-delete~_', expected: '_~emphasis-delete~_\n' },
|
||||
Strong_emphasis_delete: { input: '**_~strong-emphasis-delete~_**', expected: '*_~strong-emphasis-delete~_*\n' },
|
||||
Delete: { input: '~~strikethrough~~', expected: '~strikethrough~\n' },
|
||||
Strong_in_list: {
|
||||
input: '- first\n- **strong_second**',
|
||||
expected: '- first\n- *strong_second*\n',
|
||||
},
|
||||
Emphasize_in_table: {
|
||||
input: '| 1 | 2 |\n| - | - |\n| a | _b_ |',
|
||||
expected: '| 1 | 2 |\n| a | _b_ |\n',
|
||||
},
|
||||
}
|
||||
|
||||
const whatsappTests: Test = {
|
||||
...messengerTests,
|
||||
Code_snippet: { input: '```\n{\n\ta: null\n}\n```', expected: '```{\n\ta: null\n}```\n' },
|
||||
}
|
||||
|
||||
test.each(Object.entries(messengerTests))(
|
||||
'[Messenger] Test %s',
|
||||
(_testName: string, testValues: { input: string; expected: string }): void => {
|
||||
const actual = transformMarkdownForTwilio(testValues.input, 'messenger')
|
||||
expect(actual).toBe(testValues.expected)
|
||||
}
|
||||
)
|
||||
|
||||
test.each(Object.entries(whatsappTests))(
|
||||
'[WhatsApp] Test %s',
|
||||
(_testName: string, testValues: { input: string; expected: string }): void => {
|
||||
const actual = transformMarkdownForTwilio(testValues.input, 'whatsapp')
|
||||
expect(actual).toBe(testValues.expected)
|
||||
}
|
||||
)
|
||||
|
||||
const bigInput = `# H1
|
||||
## H2
|
||||
### H3
|
||||
**bold-asterisk**
|
||||
*italic-asterisk*
|
||||
<!-- This comment will not appear in rendered output -->
|
||||
__bold-underscore__
|
||||
_italic-underscore_
|
||||
> blockquote
|
||||
1. orderedListItem1
|
||||
2. item2
|
||||
- unorderedListItem1
|
||||
- item2
|
||||
|
||||
\`code\`
|
||||
horizontal
|
||||
|
||||
---
|
||||
|
||||
rule
|
||||
[title](https://www.example.com)
|
||||

|
||||

|
||||
| 1 | 2 |
|
||||
| - | - |
|
||||
| a | b |
|
||||
\`\`\`
|
||||
{
|
||||
a: null
|
||||
}
|
||||
\`\`\`
|
||||
footnote[^1]
|
||||
|
||||
[^1]: the footnote
|
||||
|
||||
term
|
||||
: definition
|
||||
~~strikethrough~~
|
||||
- [x] taskListItem1
|
||||
- [ ] item2
|
||||
emoji direct 😂
|
||||
`
|
||||
|
||||
const expectedForBigInputMessenger = `H1
|
||||
H2
|
||||
H3
|
||||
*bold-asterisk*
|
||||
_italic-asterisk_
|
||||
*bold-underscore*
|
||||
_italic-underscore_
|
||||
Quote: “blockquote”
|
||||
1. orderedListItem1
|
||||
2. item2
|
||||
- unorderedListItem1
|
||||
- item2
|
||||
\`code\`
|
||||
horizontal
|
||||
---
|
||||
rule
|
||||
https://www.example.com
|
||||
https://tinyurl.com/mrv4bmyk
|
||||
https://tinyurl.com/mrv4bmyk
|
||||
| 1 | 2 |
|
||||
| a | b |
|
||||
\`\`\`
|
||||
{
|
||||
a: null
|
||||
}
|
||||
\`\`\`
|
||||
footnote[1]
|
||||
term
|
||||
: definition
|
||||
~strikethrough~
|
||||
☑︎ taskListItem1
|
||||
☐ item2
|
||||
emoji direct 😂
|
||||
[1] the footnote
|
||||
`
|
||||
|
||||
const expectedForBigInputWhatsApp = `H1
|
||||
H2
|
||||
H3
|
||||
*bold-asterisk*
|
||||
_italic-asterisk_
|
||||
*bold-underscore*
|
||||
_italic-underscore_
|
||||
Quote: “blockquote”
|
||||
1. orderedListItem1
|
||||
2. item2
|
||||
- unorderedListItem1
|
||||
- item2
|
||||
\`code\`
|
||||
horizontal
|
||||
---
|
||||
rule
|
||||
https://www.example.com
|
||||
https://tinyurl.com/mrv4bmyk
|
||||
https://tinyurl.com/mrv4bmyk
|
||||
| 1 | 2 |
|
||||
| a | b |
|
||||
\`\`\`{
|
||||
a: null
|
||||
}\`\`\`
|
||||
footnote[1]
|
||||
term\n: definition
|
||||
~strikethrough~
|
||||
☑︎ taskListItem1
|
||||
☐ item2
|
||||
emoji direct 😂
|
||||
[1] the footnote
|
||||
`
|
||||
|
||||
test('[Messenger] Multi-line multi markup test', () => {
|
||||
const actual = transformMarkdownForTwilio(bigInput, 'messenger')
|
||||
expect(actual).toBe(expectedForBigInputMessenger)
|
||||
})
|
||||
|
||||
test('[WhatsApp] Multi-line multi markup test', () => {
|
||||
const actual = transformMarkdownForTwilio(bigInput, 'whatsapp')
|
||||
expect(actual).toBe(expectedForBigInputWhatsApp)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { transformMarkdown, MarkdownHandlers, stripAllHandlers } from '@botpress/common'
|
||||
import { TwilioChannel } from './twilio'
|
||||
|
||||
const messengerHandlers: MarkdownHandlers = {
|
||||
...stripAllHandlers,
|
||||
code: (node, _visit) => `\`\`\`\n${node.value}\n\`\`\`\n`,
|
||||
delete: (node, visit) => `~${visit(node)}~`,
|
||||
emphasis: (node, visit) => `_${visit(node)}_`,
|
||||
inlineCode: (node, _visit) => `\`${node.value}\``,
|
||||
strong: (node, visit) => `*${visit(node)}*`,
|
||||
}
|
||||
|
||||
const whatsappHandlers: MarkdownHandlers = {
|
||||
...stripAllHandlers,
|
||||
code: (node, _visit) => `\`\`\`${node.value}\`\`\`\n`,
|
||||
delete: (node, visit) => `~${visit(node)}~`,
|
||||
emphasis: (node, visit) => `_${visit(node)}_`,
|
||||
inlineCode: (node, _visit) => `\`${node.value}\``,
|
||||
strong: (node, visit) => `*${visit(node)}*`,
|
||||
}
|
||||
|
||||
const markdownHandlersByChannelType: Map<TwilioChannel, MarkdownHandlers> = new Map([
|
||||
['rcs', stripAllHandlers],
|
||||
['sms/mms', stripAllHandlers],
|
||||
['messenger', messengerHandlers],
|
||||
['whatsapp', whatsappHandlers],
|
||||
])
|
||||
|
||||
export function transformMarkdownForTwilio(text: string, channel: TwilioChannel) {
|
||||
return transformMarkdown(text, markdownHandlersByChannelType.get(channel))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Twilio } from 'twilio'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type TwilioChannel = 'sms/mms' | 'rcs' | 'whatsapp' | 'messenger'
|
||||
|
||||
export function getTwilioClient(ctx: bp.Context): Twilio {
|
||||
return new Twilio(ctx.configuration.accountSID, ctx.configuration.authToken)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type Handler = bp.IntegrationProps['handler']
|
||||
export type HandlerProps = bp.HandlerProps
|
||||
export type IntegrationCtx = bp.Context
|
||||
export type Logger = bp.Logger
|
||||
|
||||
export type Client = bp.Client
|
||||
export type Conversation = bp.ClientResponses['getConversation']['conversation']
|
||||
export type Message = bp.ClientResponses['getMessage']['message']
|
||||
export type User = bp.ClientResponses['getUser']['user']
|
||||
export type Event = bp.ClientResponses['getEvent']['event']
|
||||
|
||||
export type EventDefinition = sdk.EventDefinition
|
||||
export type ActionDefinition = sdk.ActionDefinition
|
||||
export type ChannelDefinition = sdk.ChannelDefinition
|
||||
export type MessageDefinition = sdk.MessageDefinition
|
||||
|
||||
export type ActionProps = bp.AnyActionProps
|
||||
export type MessageHandlerProps = bp.AnyMessageProps
|
||||
export type AckFunction = bp.AnyAckFunction
|
||||
export type CreateMessageInput = Parameters<Client['createMessage']>[0]
|
||||
export type CreateMessageInputType = CreateMessageInput['type']
|
||||
export type CreateMessageInputPayload = CreateMessageInput['payload']
|
||||
|
||||
// Channel message payload types
|
||||
export type Choice = bp.channels.channel.choice.Choice
|
||||
export type Card = bp.channels.channel.card.Card
|
||||
@@ -0,0 +1,210 @@
|
||||
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')}`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user