chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -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']
+4
View File
@@ -0,0 +1,4 @@
export { channels } from './channels'
export { configuration } from './configuration'
export { entities } from './entities'
export { user } from './user'
+10
View File
@@ -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']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+15
View File
@@ -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.
+1
View File
@@ -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',
},
},
}))
+34
View File
@@ -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 }
}
+11
View File
@@ -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 {}
}
+128
View File
@@ -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']
+104
View File
@@ -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 },
})
}
}
+15
View File
@@ -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)
![image](https://tinyurl.com/mrv4bmyk)
![test](https://tinyurl.com/mrv4bmyk)
| 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))
}
+8
View File
@@ -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)
}
+29
View File
@@ -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
+210
View File
@@ -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')}`
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config