chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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')}`
|
||||
}
|
||||
Reference in New Issue
Block a user