From 6215cfbbaf50ef8b7a0fd25c9fb2084a60cdd3d0 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Tue, 30 Jan 2024 08:02:10 +0100 Subject: [PATCH] :zap: (fileUpload) New visibility option: "Public", "Private" or "Auto" (#1196) ## Summary by CodeRabbit - **New Features** - Introduced file visibility options for uploaded files, allowing users to set files as public or private. - Added a new API endpoint for retrieving temporary URLs for files, enhancing file accessibility. - Expanded file upload documentation to include information on file visibility settings. - Updated URL validation to support URLs with port numbers and "http://localhost". - **Enhancements** - Improved media download functionality by replacing the `got` library with a custom `downloadMedia` function. - Enhanced bot flow continuation and session start logic to support a wider range of reply types, including WhatsApp media messages. - **Bug Fixes** - Adjusted file path and URL construction in the `generateUploadUrl` function to correctly reflect file visibility settings. --- .../components/FileInputSettings.tsx | 17 ++- .../results/[resultId]/[fileName].ts | 66 ++++++++++++ .../[typebotId]/whatsapp/media/[mediaId].ts | 23 ++-- .../docs/editor/blocks/inputs/file-upload.mdx | 8 ++ apps/docs/openapi/builder.json | 16 +++ apps/docs/openapi/viewer.json | 16 +++ .../fileUpload/api/generateUploadUrl.ts | 21 ++-- .../blocks/inputs/url/validateUrl.ts | 5 +- packages/bot-engine/continueBotFlow.ts | 102 ++++++++++++------ packages/bot-engine/startSession.ts | 3 +- packages/bot-engine/types.ts | 9 ++ packages/bot-engine/whatsapp/downloadMedia.ts | 30 ++++++ .../bot-engine/whatsapp/resumeWhatsAppFlow.ts | 27 +++-- .../whatsapp/startWhatsAppSession.ts | 6 +- packages/lib/s3/getFileTempUrl.ts | 27 +++++ .../features/blocks/inputs/file/constants.ts | 3 + .../features/blocks/inputs/file/schema.ts | 2 + 17 files changed, 305 insertions(+), 76 deletions(-) create mode 100644 apps/builder/src/pages/api/typebots/[typebotId]/results/[resultId]/[fileName].ts create mode 100644 packages/bot-engine/whatsapp/downloadMedia.ts create mode 100644 packages/lib/s3/getFileTempUrl.ts diff --git a/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx b/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx index 01b687caf5e..bfa6fd1fcb0 100644 --- a/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx +++ b/apps/builder/src/features/blocks/inputs/fileUpload/components/FileInputSettings.tsx @@ -5,8 +5,12 @@ import React from 'react' import { TextInput } from '@/components/inputs' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { VariableSearchInput } from '@/components/inputs/VariableSearchInput' -import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file/constants' +import { + defaultFileInputOptions, + fileVisibilityOptions, +} from '@typebot.io/schemas/features/blocks/inputs/file/constants' import { useTranslate } from '@tolgee/react' +import { DropdownList } from '@/components/DropdownList' type Props = { options: FileInputBlock['options'] @@ -37,6 +41,10 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => { const updateSkipButtonLabel = (skip: string) => onOptionsChange({ ...options, labels: { ...options?.labels, skip } }) + const updateVisibility = ( + visibility: (typeof fileVisibilityOptions)[number] + ) => onOptionsChange({ ...options, visibility }) + return ( { onChange={updateSkipButtonLabel} withVariableButton={false} /> + {options?.isMultipleAllowed diff --git a/apps/builder/src/pages/api/typebots/[typebotId]/results/[resultId]/[fileName].ts b/apps/builder/src/pages/api/typebots/[typebotId]/results/[resultId]/[fileName].ts new file mode 100644 index 00000000000..08855e35db2 --- /dev/null +++ b/apps/builder/src/pages/api/typebots/[typebotId]/results/[resultId]/[fileName].ts @@ -0,0 +1,66 @@ +import prisma from '@typebot.io/lib/prisma' +import { NextApiRequest, NextApiResponse } from 'next' +import { getAuthenticatedUser } from '@/features/auth/helpers/getAuthenticatedUser' +import { + badRequest, + methodNotAllowed, + notAuthenticated, + notFound, +} from '@typebot.io/lib/api' +import { getFileTempUrl } from '@typebot.io/lib/s3/getFileTempUrl' +import { isReadTypebotForbidden } from '@/features/typebot/helpers/isReadTypebotForbidden' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === 'GET') { + const user = await getAuthenticatedUser(req, res) + if (!user) return notAuthenticated(res) + + const typebotId = req.query.typebotId as string + const resultId = req.query.resultId as string + const fileName = req.query.fileName as string + + if (!fileName) return badRequest(res, 'fileName missing not found') + + const typebot = await prisma.typebot.findFirst({ + where: { + id: typebotId, + }, + select: { + whatsAppCredentialsId: true, + collaborators: { + select: { + userId: true, + }, + }, + workspace: { + select: { + id: true, + isSuspended: true, + isPastDue: true, + members: { + select: { + userId: true, + }, + }, + }, + }, + }, + }) + + if (!typebot?.workspace || (await isReadTypebotForbidden(typebot, user))) + return notFound(res, 'Workspace not found') + + if (!typebot) return notFound(res, 'Typebot not found') + + const tmpUrl = await getFileTempUrl({ + key: `private/workspaces/${typebot.workspace.id}/typebots/${typebotId}/results/${resultId}/${fileName}`, + }) + + if (!tmpUrl) return notFound(res, 'File not found') + + return res.redirect(tmpUrl) + } + return methodNotAllowed(res) +} + +export default handler diff --git a/apps/builder/src/pages/api/typebots/[typebotId]/whatsapp/media/[mediaId].ts b/apps/builder/src/pages/api/typebots/[typebotId]/whatsapp/media/[mediaId].ts index 0509b864920..6b176fdea6b 100644 --- a/apps/builder/src/pages/api/typebots/[typebotId]/whatsapp/media/[mediaId].ts +++ b/apps/builder/src/pages/api/typebots/[typebotId]/whatsapp/media/[mediaId].ts @@ -8,9 +8,8 @@ import { } from '@typebot.io/lib/api' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp' -import got from 'got' import { decrypt } from '@typebot.io/lib/api/encryption/decrypt' -import { env } from '@typebot.io/env' +import { downloadMedia } from '@typebot.io/bot-engine/whatsapp/downloadMedia' const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === 'GET') { @@ -61,25 +60,15 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { credentials.iv )) as WhatsAppCredentials['data'] - const { body } = await got.get({ - url: `${env.WHATSAPP_CLOUD_API_URL}/v17.0/${mediaId}`, - headers: { - Authorization: `Bearer ${credentialsData.systemUserAccessToken}`, - }, + const { file, mimeType } = await downloadMedia({ + mediaId, + systemUserAccessToken: credentialsData.systemUserAccessToken, }) - const parsedBody = JSON.parse(body) as { url: string; mime_type: string } - - const buffer = await got(parsedBody.url, { - headers: { - Authorization: `Bearer ${credentialsData.systemUserAccessToken}`, - }, - }).buffer() - - res.setHeader('Content-Type', parsedBody.mime_type) + res.setHeader('Content-Type', mimeType) res.setHeader('Cache-Control', 'public, max-age=86400') - return res.send(buffer) + return res.send(file) } return methodNotAllowed(res) } diff --git a/apps/docs/editor/blocks/inputs/file-upload.mdx b/apps/docs/editor/blocks/inputs/file-upload.mdx index eed5629e7c4..06e341eac70 100644 --- a/apps/docs/editor/blocks/inputs/file-upload.mdx +++ b/apps/docs/editor/blocks/inputs/file-upload.mdx @@ -31,3 +31,11 @@ The placeholder accepts [HTML](https://en.wikipedia.org/wiki/HTML). ## Size limit There is a 10MB fixed limit per uploaded file. If you want your respondents to upload larger files, you should ask them to upload their files to a cloud storage service (e.g. Google Drive, Dropbox, etc.) and share the link with you. + +## Visibility + +This option allows you to choose between generating public URLs for the uploaded files or keeping them private. If you choose to keep the files private, you will be able to see the file only if you are logged in to your Typebot account. + +Note that if you choose to keep the files private, you will not be able to use the file URL with other blocks like Attachment in the Send email block or others. These services won't be able to read the files. + +By default, this option is set to `Auto`. This means that the files will be public if uploaded from the web runtime but private if uploaded from the WhatsApp runtime. diff --git a/apps/docs/openapi/builder.json b/apps/docs/openapi/builder.json index 4af5105d79e..06daa623916 100644 --- a/apps/docs/openapi/builder.json +++ b/apps/docs/openapi/builder.json @@ -24342,6 +24342,14 @@ }, "sizeLimit": { "type": "number" + }, + "visibility": { + "type": "string", + "enum": [ + "Auto", + "Public", + "Private" + ] } } } @@ -27033,6 +27041,14 @@ "type": "string" } } + }, + "visibility": { + "type": "string", + "enum": [ + "Auto", + "Public", + "Private" + ] } } } diff --git a/apps/docs/openapi/viewer.json b/apps/docs/openapi/viewer.json index 4f6527179da..94774a70a1c 100644 --- a/apps/docs/openapi/viewer.json +++ b/apps/docs/openapi/viewer.json @@ -7418,6 +7418,14 @@ }, "sizeLimit": { "type": "number" + }, + "visibility": { + "type": "string", + "enum": [ + "Auto", + "Public", + "Private" + ] } } } @@ -10534,6 +10542,14 @@ "type": "string" } } + }, + "visibility": { + "type": "string", + "enum": [ + "Auto", + "Public", + "Private" + ] } } } diff --git a/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts b/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts index 955710592f1..3bcce2c67c2 100644 --- a/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts +++ b/apps/viewer/src/features/fileUpload/api/generateUploadUrl.ts @@ -140,10 +140,6 @@ export const generateUploadUrl = publicProcedure message: "Can't find workspaceId", }) - const resultId = session.state.typebotsQueue[0].resultId - - const filePath = `public/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${filePathProps.fileName}` - if (session.state.currentBlockId === undefined) throw new TRPCError({ code: 'BAD_REQUEST', @@ -163,6 +159,14 @@ export const generateUploadUrl = publicProcedure message: "Can't find file upload block", }) + const resultId = session.state.typebotsQueue[0].resultId + + const filePath = `${ + fileUploadBlock.options?.visibility === 'Private' ? 'private' : 'public' + }/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${ + filePathProps.fileName + }` + const presignedPostPolicy = await generatePresignedPostPolicy({ fileType, filePath, @@ -175,8 +179,11 @@ export const generateUploadUrl = publicProcedure return { presignedUrl: presignedPostPolicy.postURL, formData: presignedPostPolicy.formData, - fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN - ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` - : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, + fileUrl: + fileUploadBlock.options?.visibility === 'Private' + ? `${env.NEXTAUTH_URL}/api/typebots/${typebotId}/results/${resultId}/${filePathProps.fileName}` + : env.S3_PUBLIC_CUSTOM_DOMAIN + ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}` + : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, } }) diff --git a/packages/bot-engine/blocks/inputs/url/validateUrl.ts b/packages/bot-engine/blocks/inputs/url/validateUrl.ts index 37112d380a8..46d9227f165 100644 --- a/packages/bot-engine/blocks/inputs/url/validateUrl.ts +++ b/packages/bot-engine/blocks/inputs/url/validateUrl.ts @@ -1,4 +1,5 @@ const urlRegex = - /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/ + /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]:[0-9]*\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]:[0-9]*\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/ -export const validateUrl = (url: string) => urlRegex.test(url) +export const validateUrl = (url: string) => + url.startsWith('http://localhost') || urlRegex.test(url) diff --git a/packages/bot-engine/continueBotFlow.ts b/packages/bot-engine/continueBotFlow.ts index 33babbfc56d..4a7717c0bf1 100644 --- a/packages/bot-engine/continueBotFlow.ts +++ b/packages/bot-engine/continueBotFlow.ts @@ -15,7 +15,7 @@ import { validateUrl } from './blocks/inputs/url/validateUrl' import { resumeWebhookExecution } from './blocks/integrations/webhook/resumeWebhookExecution' import { upsertAnswer } from './queries/upsertAnswer' import { parseButtonsReply } from './blocks/inputs/buttons/parseButtonsReply' -import { ParsedReply } from './types' +import { ParsedReply, Reply } from './types' import { validateNumber } from './blocks/inputs/number/validateNumber' import { parseDateReply } from './blocks/inputs/date/parseDateReply' import { validateRatingReply } from './blocks/inputs/rating/validateRatingReply' @@ -39,6 +39,9 @@ import { getBlockById } from '@typebot.io/lib/getBlockById' import { ForgedBlock, forgedBlocks } from '@typebot.io/forge-schemas' import { enabledBlocks } from '@typebot.io/forge-repository' import { resumeChatCompletion } from './blocks/integrations/legacy/openai/resumeChatCompletion' +import { env } from '@typebot.io/env' +import { downloadMedia } from './whatsapp/downloadMedia' +import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket' type Params = { version: 1 | 2 @@ -46,7 +49,7 @@ type Params = { startTime?: number } export const continueBotFlow = async ( - reply: string | undefined, + reply: Reply, { state, version, startTime }: Params ): Promise< ContinueChatResponse & { @@ -75,7 +78,7 @@ export const continueBotFlow = async ( const existingVariable = state.typebotsQueue[0].typebot.variables.find( byId(block.options?.variableId) ) - if (existingVariable && reply) { + if (existingVariable && reply && typeof reply === 'string') { const newVariable = { ...existingVariable, value: safeJsonParse(reply), @@ -89,14 +92,18 @@ export const continueBotFlow = async ( block.options?.task === 'Create chat completion' ) { firstBubbleWasStreamed = true - if (reply) { + if (reply && typeof reply === 'string') { const result = await resumeChatCompletion(state, { options: block.options, outgoingEdgeId: block.outgoingEdgeId, })(reply) newSessionState = result.newSessionState } - } else if (reply && block.type === IntegrationBlockType.WEBHOOK) { + } else if ( + reply && + block.type === IntegrationBlockType.WEBHOOK && + typeof reply === 'string' + ) { const result = resumeWebhookExecution({ state, block, @@ -153,7 +160,7 @@ export const continueBotFlow = async ( let formattedReply: string | undefined if (isInputBlock(block)) { - const parsedReplyResult = parseReply(newSessionState)(reply, block) + const parsedReplyResult = await parseReply(newSessionState)(reply, block) if (parsedReplyResult.status === 'fail') return { @@ -400,73 +407,98 @@ const getOutgoingEdgeId = const parseReply = (state: SessionState) => - (inputValue: string | undefined, block: InputBlock): ParsedReply => { + async (reply: Reply, block: InputBlock): Promise => { + if (typeof reply !== 'string') { + if (block.type !== InputBlockType.FILE || !reply) + return { status: 'fail' } + if (block.options?.visibility !== 'Public') { + return { + status: 'success', + reply: + env.NEXTAUTH_URL + + `/api/typebots/${state.typebotsQueue[0].typebot.id}/whatsapp/media/${reply.mediaId}`, + } + } + const { file, mimeType } = await downloadMedia({ + mediaId: reply.mediaId, + systemUserAccessToken: reply.accessToken, + }) + const url = await uploadFileToBucket({ + file, + key: `public/workspaces/${reply.workspaceId}/typebots/${state.typebotsQueue[0].typebot.id}/results/${state.typebotsQueue[0].resultId}/${reply.mediaId}`, + mimeType, + }) + return { + status: 'success', + reply: url, + } + } switch (block.type) { case InputBlockType.EMAIL: { - if (!inputValue) return { status: 'fail' } - const isValid = validateEmail(inputValue) + if (!reply) return { status: 'fail' } + const isValid = validateEmail(reply) if (!isValid) return { status: 'fail' } - return { status: 'success', reply: inputValue } + return { status: 'success', reply: reply } } case InputBlockType.PHONE: { - if (!inputValue) return { status: 'fail' } + if (!reply) return { status: 'fail' } const formattedPhone = formatPhoneNumber( - inputValue, + reply, block.options?.defaultCountryCode ) if (!formattedPhone) return { status: 'fail' } return { status: 'success', reply: formattedPhone } } case InputBlockType.URL: { - if (!inputValue) return { status: 'fail' } - const isValid = validateUrl(inputValue) + if (!reply) return { status: 'fail' } + const isValid = validateUrl(reply) if (!isValid) return { status: 'fail' } - return { status: 'success', reply: inputValue } + return { status: 'success', reply: reply } } case InputBlockType.CHOICE: { - if (!inputValue) return { status: 'fail' } - return parseButtonsReply(state)(inputValue, block) + if (!reply) return { status: 'fail' } + return parseButtonsReply(state)(reply, block) } case InputBlockType.NUMBER: { - if (!inputValue) return { status: 'fail' } - const isValid = validateNumber(inputValue, { + if (!reply) return { status: 'fail' } + const isValid = validateNumber(reply, { options: block.options, variables: state.typebotsQueue[0].typebot.variables, }) if (!isValid) return { status: 'fail' } - return { status: 'success', reply: parseNumber(inputValue) } + return { status: 'success', reply: parseNumber(reply) } } case InputBlockType.DATE: { - if (!inputValue) return { status: 'fail' } - return parseDateReply(inputValue, block) + if (!reply) return { status: 'fail' } + return parseDateReply(reply, block) } case InputBlockType.FILE: { - if (!inputValue) + if (!reply) return block.options?.isRequired ?? defaultFileInputOptions.isRequired ? { status: 'fail' } : { status: 'skip' } - const urls = inputValue.split(', ') + const urls = reply.split(', ') const status = urls.some((url) => validateUrl(url)) ? 'success' : 'fail' - return { status, reply: inputValue } + return { status, reply: reply } } case InputBlockType.PAYMENT: { - if (!inputValue) return { status: 'fail' } - if (inputValue === 'fail') return { status: 'fail' } - return { status: 'success', reply: inputValue } + if (!reply) return { status: 'fail' } + if (reply === 'fail') return { status: 'fail' } + return { status: 'success', reply: reply } } case InputBlockType.RATING: { - if (!inputValue) return { status: 'fail' } - const isValid = validateRatingReply(inputValue, block) + if (!reply) return { status: 'fail' } + const isValid = validateRatingReply(reply, block) if (!isValid) return { status: 'fail' } - return { status: 'success', reply: inputValue } + return { status: 'success', reply: reply } } case InputBlockType.PICTURE_CHOICE: { - if (!inputValue) return { status: 'fail' } - return parsePictureChoicesReply(state)(inputValue, block) + if (!reply) return { status: 'fail' } + return parsePictureChoicesReply(state)(reply, block) } case InputBlockType.TEXT: { - if (!inputValue) return { status: 'fail' } - return { status: 'success', reply: inputValue } + if (!reply) return { status: 'fail' } + return { status: 'success', reply: reply } } } } diff --git a/packages/bot-engine/startSession.ts b/packages/bot-engine/startSession.ts index 7c7ad3ef712..8fbe86d4fea 100644 --- a/packages/bot-engine/startSession.ts +++ b/packages/bot-engine/startSession.ts @@ -37,6 +37,7 @@ import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constan import { VisitedEdge } from '@typebot.io/prisma' import { env } from '@typebot.io/env' import { getFirstEdgeId } from './getFirstEdgeId' +import { Reply } from './types' type StartParams = | ({ @@ -49,7 +50,7 @@ type StartParams = type Props = { version: 1 | 2 - message: string | undefined + message: Reply startParams: StartParams initialSessionState?: Pick } diff --git a/packages/bot-engine/types.ts b/packages/bot-engine/types.ts index 912c1da5c2e..6e273f1db4d 100644 --- a/packages/bot-engine/types.ts +++ b/packages/bot-engine/types.ts @@ -18,6 +18,15 @@ export type ExecuteIntegrationResponse = { customEmbedBubble?: CustomEmbedBubble } & Pick +type WhatsAppMediaMessage = { + type: 'whatsapp media' + mediaId: string + workspaceId?: string + accessToken: string +} + +export type Reply = string | WhatsAppMediaMessage | undefined + export type ParsedReply = | { status: 'success'; reply: string } | { status: 'fail' } diff --git a/packages/bot-engine/whatsapp/downloadMedia.ts b/packages/bot-engine/whatsapp/downloadMedia.ts new file mode 100644 index 00000000000..aead0cf5c7c --- /dev/null +++ b/packages/bot-engine/whatsapp/downloadMedia.ts @@ -0,0 +1,30 @@ +import { env } from '@typebot.io/env' +import got from 'got' + +type Props = { + mediaId: string + systemUserAccessToken: string +} + +export const downloadMedia = async ({ + mediaId, + systemUserAccessToken, +}: Props): Promise<{ file: Buffer; mimeType: string }> => { + const { body } = await got.get({ + url: `${env.WHATSAPP_CLOUD_API_URL}/v17.0/${mediaId}`, + headers: { + Authorization: `Bearer ${systemUserAccessToken}`, + }, + }) + + const parsedBody = JSON.parse(body) as { url: string; mime_type: string } + + return { + file: await got(parsedBody.url, { + headers: { + Authorization: `Bearer ${systemUserAccessToken}`, + }, + }).buffer(), + mimeType: parsedBody.mime_type, + } +} diff --git a/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts b/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts index 53ab07c7485..9b62484c78f 100644 --- a/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts +++ b/packages/bot-engine/whatsapp/resumeWhatsAppFlow.ts @@ -12,6 +12,7 @@ import { decrypt } from '@typebot.io/lib/api/encryption/decrypt' import { saveStateToDatabase } from '../saveStateToDatabase' import prisma from '@typebot.io/lib/prisma' import { isDefined } from '@typebot.io/lib/utils' +import { Reply } from '../types' type Props = { receivedMessage: WhatsAppIncomingMessage @@ -43,10 +44,6 @@ export const resumeWhatsAppFlow = async ({ const isPreview = workspaceId === undefined || credentialsId === undefined const { typebot } = session?.state.typebotsQueue[0] ?? {} - const messageContent = await getIncomingMessageContent({ - message: receivedMessage, - typebotId: typebot?.id, - }) const credentials = await getCredentials({ credentialsId, isPreview }) @@ -57,6 +54,13 @@ export const resumeWhatsAppFlow = async ({ } } + const reply = await getIncomingMessageContent({ + message: receivedMessage, + typebotId: typebot?.id, + workspaceId, + accessToken: credentials?.systemUserAccessToken, + }) + const isSessionExpired = session && isDefined(session.state.expiryTimeout) && @@ -64,13 +68,13 @@ export const resumeWhatsAppFlow = async ({ const resumeResponse = session && !isSessionExpired - ? await continueBotFlow(messageContent, { + ? await continueBotFlow(reply, { version: 2, state: { ...session.state, whatsApp: { contact } }, }) : workspaceId ? await startWhatsAppSession({ - incomingMessage: messageContent, + incomingMessage: reply, workspaceId, credentials: { ...credentials, id: credentialsId as string }, contact, @@ -128,10 +132,14 @@ export const resumeWhatsAppFlow = async ({ const getIncomingMessageContent = async ({ message, typebotId, + workspaceId, + accessToken, }: { message: WhatsAppIncomingMessage typebotId?: string -}): Promise => { + workspaceId?: string + accessToken: string +}): Promise => { switch (message.type) { case 'text': return message.text.body @@ -151,10 +159,7 @@ const getIncomingMessageContent = async ({ if (message.type === 'audio') mediaId = message.audio.id if (message.type === 'document') mediaId = message.document.id if (!mediaId) return - return ( - env.NEXTAUTH_URL + - `/api/typebots/${typebotId}/whatsapp/media/${mediaId}` - ) + return { type: 'whatsapp media', mediaId, workspaceId, accessToken } case 'location': return `${message.location.latitude}, ${message.location.longitude}` } diff --git a/packages/bot-engine/whatsapp/startWhatsAppSession.ts b/packages/bot-engine/whatsapp/startWhatsAppSession.ts index c1439f6cef0..8a4a4096a2d 100644 --- a/packages/bot-engine/whatsapp/startWhatsAppSession.ts +++ b/packages/bot-engine/whatsapp/startWhatsAppSession.ts @@ -17,9 +17,10 @@ import { ComparisonOperators, } from '@typebot.io/schemas/features/blocks/logic/condition/constants' import { VisitedEdge } from '@typebot.io/prisma' +import { Reply } from '../types' type Props = { - incomingMessage?: string + incomingMessage?: Reply workspaceId: string credentials: WhatsAppCredentials['data'] & Pick contact: NonNullable['contact'] @@ -104,10 +105,11 @@ export const startWhatsAppSession = async ({ } export const messageMatchStartCondition = ( - message: string, + message: Reply, startCondition: NonNullable['startCondition'] ) => { if (!startCondition) return true + if (typeof message !== 'string') return false return startCondition.logicalOperator === LogicalOperator.AND ? startCondition.comparisons.every((comparison) => matchComparison( diff --git a/packages/lib/s3/getFileTempUrl.ts b/packages/lib/s3/getFileTempUrl.ts new file mode 100644 index 00000000000..21bd0a4e21d --- /dev/null +++ b/packages/lib/s3/getFileTempUrl.ts @@ -0,0 +1,27 @@ +import { env } from '@typebot.io/env' +import { Client } from 'minio' + +type Props = { + key: string + expires?: number +} +export const getFileTempUrl = async ({ + key, + expires, +}: Props): Promise => { + if (!env.S3_ENDPOINT || !env.S3_ACCESS_KEY || !env.S3_SECRET_KEY) + throw new Error( + 'S3 not properly configured. Missing one of those variables: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY' + ) + + const minioClient = new Client({ + endPoint: env.S3_ENDPOINT, + port: env.S3_PORT, + useSSL: env.S3_SSL, + accessKey: env.S3_ACCESS_KEY, + secretKey: env.S3_SECRET_KEY, + region: env.S3_REGION, + }) + + return minioClient.presignedGetObject(env.S3_BUCKET, key, expires ?? 3600) +} diff --git a/packages/schemas/features/blocks/inputs/file/constants.ts b/packages/schemas/features/blocks/inputs/file/constants.ts index 53e60c4a60e..17059c1a63f 100644 --- a/packages/schemas/features/blocks/inputs/file/constants.ts +++ b/packages/schemas/features/blocks/inputs/file/constants.ts @@ -3,6 +3,7 @@ import { FileInputBlock } from './schema' export const defaultFileInputOptions = { isRequired: true, isMultipleAllowed: false, + visibility: 'Auto', labels: { placeholder: ` Click to upload @@ -13,3 +14,5 @@ export const defaultFileInputOptions = { skip: 'Skip', }, } as const satisfies FileInputBlock['options'] + +export const fileVisibilityOptions = ['Auto', 'Public', 'Private'] as const diff --git a/packages/schemas/features/blocks/inputs/file/schema.ts b/packages/schemas/features/blocks/inputs/file/schema.ts index 14c40c1b038..f2cf58dae8d 100644 --- a/packages/schemas/features/blocks/inputs/file/schema.ts +++ b/packages/schemas/features/blocks/inputs/file/schema.ts @@ -1,6 +1,7 @@ import { z } from '../../../../zod' import { optionBaseSchema, blockBaseSchema } from '../../shared' import { InputBlockType } from '../constants' +import { fileVisibilityOptions } from './constants' const fileInputOptionsV5Schema = optionBaseSchema.merge( z.object({ @@ -15,6 +16,7 @@ const fileInputOptionsV5Schema = optionBaseSchema.merge( }) .optional(), sizeLimit: z.number().optional(), + visibility: z.enum(fileVisibilityOptions).optional(), }) )