diff --git a/integrations/twilio/integration.definition.ts b/integrations/twilio/integration.definition.ts index f65cba24a6e..28a4f47efc4 100644 --- a/integrations/twilio/integration.definition.ts +++ b/integrations/twilio/integration.definition.ts @@ -4,7 +4,7 @@ import { sentry as sentryHelpers } from '@botpress/sdk-addons' export default new IntegrationDefinition({ name: 'twilio', - version: '0.4.6', + version: '0.5.0', title: 'Twilio', description: 'Send and receive messages, voice calls, emails, SMS, and more.', icon: 'icon.svg', @@ -13,6 +13,21 @@ export default new IntegrationDefinition({ schema: z.object({ accountSID: z.string().min(1), authToken: z.string().min(1), + downloadMedia: z + .boolean() + .default(true) + .title('Download Media') + .describe( + 'Automatically download media files using the Files API for content access. If disabled, temporary Twilio media URLs will be used, which require authentication.' + ), + downloadedMediaExpiry: z + .number() + .default(24) + .optional() + .title('Downloaded Media Expiry') + .describe( + 'Expiry time in hours for downloaded media files. An expiry time of 0 means the files will never expire.' + ), }), }, channels: { diff --git a/integrations/twilio/package.json b/integrations/twilio/package.json index 5382af59884..59232b541bb 100644 --- a/integrations/twilio/package.json +++ b/integrations/twilio/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "build": "bp add -y && bp build", + "deploy": "bp deploy", "check:type": "tsc --noEmit", "check:bplint": "bp lint" }, @@ -11,6 +12,7 @@ "@botpress/client": "workspace:*", "@botpress/sdk": "workspace:*", "@botpress/sdk-addons": "workspace:*", + "axios": "^1.6.0", "query-string": "^6.14.1", "twilio": "^3.84.0" }, diff --git a/integrations/twilio/src/index.ts b/integrations/twilio/src/index.ts index c8c69df9cd5..01a3fc39b50 100644 --- a/integrations/twilio/src/index.ts +++ b/integrations/twilio/src/index.ts @@ -1,5 +1,7 @@ import { RuntimeError } from '@botpress/client' import { sentry as sentryHelpers } from '@botpress/sdk-addons' +import axios from 'axios' +import * as crypto from 'crypto' import queryString from 'query-string' import { Twilio } from 'twilio' import * as types from './types' @@ -53,7 +55,7 @@ const integration = new bp.Integration({ }, }, }, - handler: async ({ req, client }) => { + handler: async ({ req, client, ctx, logger }) => { console.info('Handler received request') if (!req.body) { @@ -63,6 +65,15 @@ const integration = new bp.Integration({ const data = queryString.parse(req.body) + // Twilio webhook data structure for media messages: + // - NumMedia: Number of media files (e.g., "1", "2") + // - MediaUrl0, MediaUrl1, etc.: URLs to the media files + // - MediaContentType0, MediaContentType1, etc.: MIME types of the media files + // - Body: Text message (may be empty if only media is sent) + // - From: Sender's phone number + // - To: Recipient's phone number (your Twilio number) + // - MessageSid: Unique identifier for the message + const userPhone = data.From if (typeof userPhone !== 'string') { @@ -96,18 +107,55 @@ const integration = new bp.Integration({ } const text = data.Body + const numMedia = parseInt((data.NumMedia as string) || '0', 10) + + // If there's media, create appropriate message types for each media + if (numMedia > 0) { + for (let i = 0; i < numMedia; i++) { + const mediaUrl = data[`MediaUrl${i}` as keyof typeof data] + + // Guard condition: skip invalid media URLs + if (!mediaUrl || typeof mediaUrl !== 'string') { + logger.forBot().error(`Missing or invalid media URL for media ${i + 1}`) + continue + } + + try { + // Get media metadata first + const metadata = await getTwilioMediaMetadata(mediaUrl, ctx) + + // Download media if configuration is enabled, otherwise use original URL + let finalMediaUrl = mediaUrl + if (ctx.configuration.downloadMedia) { + finalMediaUrl = await _downloadTwilioMedia(mediaUrl, client, ctx) + } - if (typeof text !== 'string') { - throw new Error('Handler received an invalid text') + // Determine message type based on MIME type and create payload + const { messageType, payload } = getMessageTypeAndPayload(finalMediaUrl, metadata.mimeType, metadata.fileName) + + await client.createMessage({ + tags: { id: `${messageSid}_media_${i}` }, + type: messageType, + userId: user.id, + conversationId: conversation.id, + payload, + }) + } catch (error) { + logger.forBot().error(`Failed to create message for media ${i + 1}:`, error) + } + } } - await client.createMessage({ - tags: { id: messageSid }, - type: 'text', - userId: user.id, - conversationId: conversation.id, - payload: { text }, - }) + // Create text message if text is present (regardless of whether media was also sent) + if (typeof text === 'string' && text.trim()) { + await client.createMessage({ + tags: { id: messageSid }, + type: 'text', + userId: user.id, + conversationId: conversation.id, + payload: { text }, + }) + } console.info('Handler received request', data) }, @@ -197,6 +245,158 @@ type SendMessageProps = Pick { + try { + const headResponse = await axios.head(mediaUrl, { + auth: { + username: ctx.configuration.accountSID, + password: ctx.configuration.authToken, + }, + }) + + const mimeType = headResponse.headers['content-type'] || 'application/octet-stream' + const fileSize = parseInt(headResponse.headers['content-length'] || '0', 10) + + // Try to extract filename from content-disposition header + let fileName: string | undefined + const contentDisposition = headResponse.headers['content-disposition'] + if (contentDisposition) { + const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^"]+)"?/i) + const rawFileName = match?.[1] + if (rawFileName) { + fileName = decodeURIComponent(rawFileName) + } + } + + return { + mimeType, + fileSize, + fileName, + } + } catch (error) { + throw new RuntimeError( + `Failed to get Twilio media metadata: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } +} + +/** + * Downloads Twilio media and stores it in the Botpress file API + */ +async function _downloadTwilioMedia(mediaUrl: string, client: bp.Client, ctx: bp.Context): Promise { + try { + // Get file metadata + const { mimeType, fileSize } = await getTwilioMediaMetadata(mediaUrl, ctx) + + // Generate a unique ID from the URL + const buffer = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(mediaUrl)) + const uniqueId = Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .slice(0, 24) + + // Create file in Botpress file API + const { file } = await client.upsertFile({ + key: `twilio-media_${uniqueId}`, + expiresAt: _getMediaExpiry(ctx), + contentType: mimeType, + accessPolicies: ['public_content'], + publicContentImmediatelyAccessible: true, + size: fileSize, + tags: { + source: 'integration', + integration: 'twilio', + channel: 'channel', + originUrl: mediaUrl, + }, + }) + + // Download the media from Twilio + const downloadResponse = await axios.get(mediaUrl, { + responseType: 'stream', + auth: { + username: ctx.configuration.accountSID, + password: ctx.configuration.authToken, + }, + }) + + // Upload to Botpress file API + await axios.put(file.uploadUrl, downloadResponse.data, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': fileSize, + 'x-amz-tagging': 'public=true', + }, + maxBodyLength: fileSize, + }) + + return file.url + } catch (error) { + throw new RuntimeError( + `Failed to download Twilio media: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } +} + +/** + * Gets media expiry time based on configuration + */ +function _getMediaExpiry(ctx: bp.Context): string | undefined { + const expiryDelayHours = (ctx.configuration as any).downloadedMediaExpiry || 0 + if (expiryDelayHours === 0) { + return undefined + } + const expiresAt = new Date(Date.now() + expiryDelayHours * 60 * 60 * 1000) + return expiresAt.toISOString() +} + +/** + * Determines the appropriate message type and payload based on MIME type + */ +function getMessageTypeAndPayload( + mediaUrl: string, + contentType: string | null | undefined, + fileName?: string +): { messageType: 'image' | 'audio' | 'video' | 'file'; payload: any } { + const mimeType = contentType?.toLowerCase() || '' + + if (mimeType.startsWith('image/')) { + return { + messageType: 'image', + payload: { imageUrl: mediaUrl }, + } + } + + if (mimeType.startsWith('audio/')) { + return { + messageType: 'audio', + payload: { audioUrl: mediaUrl }, + } + } + + if (mimeType.startsWith('video/')) { + return { + messageType: 'video', + payload: { videoUrl: mediaUrl }, + } + } + + // Default to file for other types (documents, etc.) + return { + messageType: 'file', + payload: { + fileUrl: mediaUrl, + title: fileName || contentType || 'file', + }, + } +} + async function sendMessage({ ctx, conversation, ack, mediaUrl, text }: SendMessageProps) { const twilioClient = new Twilio(ctx.configuration.accountSID, ctx.configuration.authToken) const { to, from } = getPhoneNumbers(conversation) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26c30e9028f..5c46a709671 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - patchedDependencies: source-map-js@1.2.1: hash: h25dep36e76b3zca3v6s2554fi @@ -1557,6 +1553,9 @@ importers: '@botpress/sdk-addons': specifier: workspace:* version: link:../../packages/sdk-addons + axios: + specifier: ^1.6.0 + version: 1.8.4 query-string: specifier: ^6.14.1 version: 6.14.1 @@ -2249,7 +2248,7 @@ importers: version: 4.17.21 tsup: specifier: ^8.0.2 - version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) + version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) packages/cognitive: dependencies: @@ -2292,7 +2291,7 @@ importers: version: 11.1.6 tsup: specifier: ^8.0.2 - version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) + version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) packages/common: dependencies: @@ -2465,7 +2464,7 @@ importers: version: 0.3.0(esbuild@0.16.17) tsup: specifier: ^8.0.2 - version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) + version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) packages/sdk-addons: dependencies: @@ -2517,7 +2516,7 @@ importers: version: 9.3.5 tsup: specifier: ^8.0.2 - version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) + version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) packages/zai: dependencies: @@ -2575,7 +2574,7 @@ importers: version: 11.1.6 tsup: specifier: ^8.0.2 - version: 8.0.2(ts-node@10.9.2)(typescript@5.6.3) + version: 8.0.2(@microsoft/api-extractor@7.49.0)(ts-node@10.9.2)(typescript@5.8.3) plugins/analytics: dependencies: @@ -9610,6 +9609,7 @@ packages: proxy-from-env: 1.1.0 transitivePeerDependencies: - debug + dev: false /axios@1.2.5: resolution: {integrity: sha512-9pU/8mmjSSOb4CXVsvGIevN+MlO/t9OWtKadTaLuN85Gge3HGorUckgp8A/2FH4V4hJ7JuQ3LIeI7KAV9ITZrQ==} @@ -17761,45 +17761,6 @@ packages: - ts-node dev: true - /tsup@8.0.2(ts-node@10.9.2)(typescript@5.6.3): - resolution: {integrity: sha512-NY8xtQXdH7hDUAZwcQdY/Vzlw9johQsaqf7iwZ6g1DOUlFYQ5/AtVAjTvihhEyeRlGo4dLRVHtrRaL35M1daqQ==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - dependencies: - bundle-require: 4.0.2(esbuild@0.19.12) - cac: 6.7.14 - chokidar: 3.5.3 - debug: 4.4.0 - esbuild: 0.19.12 - execa: 5.1.1 - globby: 11.1.0 - joycon: 3.1.1 - postcss-load-config: 4.0.2(ts-node@10.9.2) - resolve-from: 5.0.0 - rollup: 4.24.2 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tree-kill: 1.2.2 - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - ts-node - dev: true - /tsx@4.19.2: resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==} engines: {node: '>=18.0.0'} @@ -18914,3 +18875,7 @@ packages: /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false