2
0

(fileUpload) New visibility option: "Public", "Private" or "Auto" (#1196)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2024-01-30 08:02:10 +01:00
committed by GitHub
parent 515fcafcd8
commit 6215cfbbaf
17 changed files with 305 additions and 76 deletions

View File

@ -5,8 +5,12 @@ import React from 'react'
import { TextInput } from '@/components/inputs' import { TextInput } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel' import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput' 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 { useTranslate } from '@tolgee/react'
import { DropdownList } from '@/components/DropdownList'
type Props = { type Props = {
options: FileInputBlock['options'] options: FileInputBlock['options']
@ -37,6 +41,10 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
const updateSkipButtonLabel = (skip: string) => const updateSkipButtonLabel = (skip: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, skip } }) onOptionsChange({ ...options, labels: { ...options?.labels, skip } })
const updateVisibility = (
visibility: (typeof fileVisibilityOptions)[number]
) => onOptionsChange({ ...options, visibility })
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<SwitchWithLabel <SwitchWithLabel
@ -91,6 +99,13 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
onChange={updateSkipButtonLabel} onChange={updateSkipButtonLabel}
withVariableButton={false} withVariableButton={false}
/> />
<DropdownList
label="Visibility:"
moreInfoTooltip='This setting determines who can see the uploaded files. "Public" means that anyone who has the link can see the files. "Private" means that only a members of this workspace can see the files.'
currentItem={options?.visibility}
onItemSelect={updateVisibility}
items={fileVisibilityOptions}
/>
<Stack> <Stack>
<FormLabel mb="0" htmlFor="variable"> <FormLabel mb="0" htmlFor="variable">
{options?.isMultipleAllowed {options?.isMultipleAllowed

View File

@ -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

View File

@ -8,9 +8,8 @@ import {
} from '@typebot.io/lib/api' } from '@typebot.io/lib/api'
import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden' import { isReadWorkspaceFobidden } from '@/features/workspace/helpers/isReadWorkspaceFobidden'
import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp' import { WhatsAppCredentials } from '@typebot.io/schemas/features/whatsapp'
import got from 'got'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt' 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) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') { if (req.method === 'GET') {
@ -61,25 +60,15 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
credentials.iv credentials.iv
)) as WhatsAppCredentials['data'] )) as WhatsAppCredentials['data']
const { body } = await got.get({ const { file, mimeType } = await downloadMedia({
url: `${env.WHATSAPP_CLOUD_API_URL}/v17.0/${mediaId}`, mediaId,
headers: { systemUserAccessToken: credentialsData.systemUserAccessToken,
Authorization: `Bearer ${credentialsData.systemUserAccessToken}`,
},
}) })
const parsedBody = JSON.parse(body) as { url: string; mime_type: string } res.setHeader('Content-Type', mimeType)
const buffer = await got(parsedBody.url, {
headers: {
Authorization: `Bearer ${credentialsData.systemUserAccessToken}`,
},
}).buffer()
res.setHeader('Content-Type', parsedBody.mime_type)
res.setHeader('Cache-Control', 'public, max-age=86400') res.setHeader('Cache-Control', 'public, max-age=86400')
return res.send(buffer) return res.send(file)
} }
return methodNotAllowed(res) return methodNotAllowed(res)
} }

View File

@ -31,3 +31,11 @@ The placeholder accepts [HTML](https://en.wikipedia.org/wiki/HTML).
## Size limit ## 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. 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.

View File

@ -24342,6 +24342,14 @@
}, },
"sizeLimit": { "sizeLimit": {
"type": "number" "type": "number"
},
"visibility": {
"type": "string",
"enum": [
"Auto",
"Public",
"Private"
]
} }
} }
} }
@ -27033,6 +27041,14 @@
"type": "string" "type": "string"
} }
} }
},
"visibility": {
"type": "string",
"enum": [
"Auto",
"Public",
"Private"
]
} }
} }
} }

View File

@ -7418,6 +7418,14 @@
}, },
"sizeLimit": { "sizeLimit": {
"type": "number" "type": "number"
},
"visibility": {
"type": "string",
"enum": [
"Auto",
"Public",
"Private"
]
} }
} }
} }
@ -10534,6 +10542,14 @@
"type": "string" "type": "string"
} }
} }
},
"visibility": {
"type": "string",
"enum": [
"Auto",
"Public",
"Private"
]
} }
} }
} }

View File

@ -140,10 +140,6 @@ export const generateUploadUrl = publicProcedure
message: "Can't find workspaceId", 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) if (session.state.currentBlockId === undefined)
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@ -163,6 +159,14 @@ export const generateUploadUrl = publicProcedure
message: "Can't find file upload block", 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({ const presignedPostPolicy = await generatePresignedPostPolicy({
fileType, fileType,
filePath, filePath,
@ -175,7 +179,10 @@ export const generateUploadUrl = publicProcedure
return { return {
presignedUrl: presignedPostPolicy.postURL, presignedUrl: presignedPostPolicy.postURL,
formData: presignedPostPolicy.formData, formData: presignedPostPolicy.formData,
fileUrl: env.S3_PUBLIC_CUSTOM_DOMAIN 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}` ? `${env.S3_PUBLIC_CUSTOM_DOMAIN}/${filePath}`
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`, : `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
} }

View File

@ -1,4 +1,5 @@
const urlRegex = 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)

View File

@ -15,7 +15,7 @@ import { validateUrl } from './blocks/inputs/url/validateUrl'
import { resumeWebhookExecution } from './blocks/integrations/webhook/resumeWebhookExecution' import { resumeWebhookExecution } from './blocks/integrations/webhook/resumeWebhookExecution'
import { upsertAnswer } from './queries/upsertAnswer' import { upsertAnswer } from './queries/upsertAnswer'
import { parseButtonsReply } from './blocks/inputs/buttons/parseButtonsReply' import { parseButtonsReply } from './blocks/inputs/buttons/parseButtonsReply'
import { ParsedReply } from './types' import { ParsedReply, Reply } from './types'
import { validateNumber } from './blocks/inputs/number/validateNumber' import { validateNumber } from './blocks/inputs/number/validateNumber'
import { parseDateReply } from './blocks/inputs/date/parseDateReply' import { parseDateReply } from './blocks/inputs/date/parseDateReply'
import { validateRatingReply } from './blocks/inputs/rating/validateRatingReply' 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 { ForgedBlock, forgedBlocks } from '@typebot.io/forge-schemas'
import { enabledBlocks } from '@typebot.io/forge-repository' import { enabledBlocks } from '@typebot.io/forge-repository'
import { resumeChatCompletion } from './blocks/integrations/legacy/openai/resumeChatCompletion' 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 = { type Params = {
version: 1 | 2 version: 1 | 2
@ -46,7 +49,7 @@ type Params = {
startTime?: number startTime?: number
} }
export const continueBotFlow = async ( export const continueBotFlow = async (
reply: string | undefined, reply: Reply,
{ state, version, startTime }: Params { state, version, startTime }: Params
): Promise< ): Promise<
ContinueChatResponse & { ContinueChatResponse & {
@ -75,7 +78,7 @@ export const continueBotFlow = async (
const existingVariable = state.typebotsQueue[0].typebot.variables.find( const existingVariable = state.typebotsQueue[0].typebot.variables.find(
byId(block.options?.variableId) byId(block.options?.variableId)
) )
if (existingVariable && reply) { if (existingVariable && reply && typeof reply === 'string') {
const newVariable = { const newVariable = {
...existingVariable, ...existingVariable,
value: safeJsonParse(reply), value: safeJsonParse(reply),
@ -89,14 +92,18 @@ export const continueBotFlow = async (
block.options?.task === 'Create chat completion' block.options?.task === 'Create chat completion'
) { ) {
firstBubbleWasStreamed = true firstBubbleWasStreamed = true
if (reply) { if (reply && typeof reply === 'string') {
const result = await resumeChatCompletion(state, { const result = await resumeChatCompletion(state, {
options: block.options, options: block.options,
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
})(reply) })(reply)
newSessionState = result.newSessionState newSessionState = result.newSessionState
} }
} else if (reply && block.type === IntegrationBlockType.WEBHOOK) { } else if (
reply &&
block.type === IntegrationBlockType.WEBHOOK &&
typeof reply === 'string'
) {
const result = resumeWebhookExecution({ const result = resumeWebhookExecution({
state, state,
block, block,
@ -153,7 +160,7 @@ export const continueBotFlow = async (
let formattedReply: string | undefined let formattedReply: string | undefined
if (isInputBlock(block)) { if (isInputBlock(block)) {
const parsedReplyResult = parseReply(newSessionState)(reply, block) const parsedReplyResult = await parseReply(newSessionState)(reply, block)
if (parsedReplyResult.status === 'fail') if (parsedReplyResult.status === 'fail')
return { return {
@ -400,73 +407,98 @@ const getOutgoingEdgeId =
const parseReply = const parseReply =
(state: SessionState) => (state: SessionState) =>
(inputValue: string | undefined, block: InputBlock): ParsedReply => { async (reply: Reply, block: InputBlock): Promise<ParsedReply> => {
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) { switch (block.type) {
case InputBlockType.EMAIL: { case InputBlockType.EMAIL: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
const isValid = validateEmail(inputValue) const isValid = validateEmail(reply)
if (!isValid) return { status: 'fail' } if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue } return { status: 'success', reply: reply }
} }
case InputBlockType.PHONE: { case InputBlockType.PHONE: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
const formattedPhone = formatPhoneNumber( const formattedPhone = formatPhoneNumber(
inputValue, reply,
block.options?.defaultCountryCode block.options?.defaultCountryCode
) )
if (!formattedPhone) return { status: 'fail' } if (!formattedPhone) return { status: 'fail' }
return { status: 'success', reply: formattedPhone } return { status: 'success', reply: formattedPhone }
} }
case InputBlockType.URL: { case InputBlockType.URL: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
const isValid = validateUrl(inputValue) const isValid = validateUrl(reply)
if (!isValid) return { status: 'fail' } if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue } return { status: 'success', reply: reply }
} }
case InputBlockType.CHOICE: { case InputBlockType.CHOICE: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
return parseButtonsReply(state)(inputValue, block) return parseButtonsReply(state)(reply, block)
} }
case InputBlockType.NUMBER: { case InputBlockType.NUMBER: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
const isValid = validateNumber(inputValue, { const isValid = validateNumber(reply, {
options: block.options, options: block.options,
variables: state.typebotsQueue[0].typebot.variables, variables: state.typebotsQueue[0].typebot.variables,
}) })
if (!isValid) return { status: 'fail' } if (!isValid) return { status: 'fail' }
return { status: 'success', reply: parseNumber(inputValue) } return { status: 'success', reply: parseNumber(reply) }
} }
case InputBlockType.DATE: { case InputBlockType.DATE: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
return parseDateReply(inputValue, block) return parseDateReply(reply, block)
} }
case InputBlockType.FILE: { case InputBlockType.FILE: {
if (!inputValue) if (!reply)
return block.options?.isRequired ?? defaultFileInputOptions.isRequired return block.options?.isRequired ?? defaultFileInputOptions.isRequired
? { status: 'fail' } ? { status: 'fail' }
: { status: 'skip' } : { status: 'skip' }
const urls = inputValue.split(', ') const urls = reply.split(', ')
const status = urls.some((url) => validateUrl(url)) ? 'success' : 'fail' const status = urls.some((url) => validateUrl(url)) ? 'success' : 'fail'
return { status, reply: inputValue } return { status, reply: reply }
} }
case InputBlockType.PAYMENT: { case InputBlockType.PAYMENT: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
if (inputValue === 'fail') return { status: 'fail' } if (reply === 'fail') return { status: 'fail' }
return { status: 'success', reply: inputValue } return { status: 'success', reply: reply }
} }
case InputBlockType.RATING: { case InputBlockType.RATING: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
const isValid = validateRatingReply(inputValue, block) const isValid = validateRatingReply(reply, block)
if (!isValid) return { status: 'fail' } if (!isValid) return { status: 'fail' }
return { status: 'success', reply: inputValue } return { status: 'success', reply: reply }
} }
case InputBlockType.PICTURE_CHOICE: { case InputBlockType.PICTURE_CHOICE: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
return parsePictureChoicesReply(state)(inputValue, block) return parsePictureChoicesReply(state)(reply, block)
} }
case InputBlockType.TEXT: { case InputBlockType.TEXT: {
if (!inputValue) return { status: 'fail' } if (!reply) return { status: 'fail' }
return { status: 'success', reply: inputValue } return { status: 'success', reply: reply }
} }
} }
} }

View File

@ -37,6 +37,7 @@ import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constan
import { VisitedEdge } from '@typebot.io/prisma' import { VisitedEdge } from '@typebot.io/prisma'
import { env } from '@typebot.io/env' import { env } from '@typebot.io/env'
import { getFirstEdgeId } from './getFirstEdgeId' import { getFirstEdgeId } from './getFirstEdgeId'
import { Reply } from './types'
type StartParams = type StartParams =
| ({ | ({
@ -49,7 +50,7 @@ type StartParams =
type Props = { type Props = {
version: 1 | 2 version: 1 | 2
message: string | undefined message: Reply
startParams: StartParams startParams: StartParams
initialSessionState?: Pick<SessionState, 'whatsApp' | 'expiryTimeout'> initialSessionState?: Pick<SessionState, 'whatsApp' | 'expiryTimeout'>
} }

View File

@ -18,6 +18,15 @@ export type ExecuteIntegrationResponse = {
customEmbedBubble?: CustomEmbedBubble customEmbedBubble?: CustomEmbedBubble
} & Pick<ContinueChatResponse, 'clientSideActions' | 'logs'> } & Pick<ContinueChatResponse, 'clientSideActions' | 'logs'>
type WhatsAppMediaMessage = {
type: 'whatsapp media'
mediaId: string
workspaceId?: string
accessToken: string
}
export type Reply = string | WhatsAppMediaMessage | undefined
export type ParsedReply = export type ParsedReply =
| { status: 'success'; reply: string } | { status: 'success'; reply: string }
| { status: 'fail' } | { status: 'fail' }

View File

@ -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,
}
}

View File

@ -12,6 +12,7 @@ import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
import { saveStateToDatabase } from '../saveStateToDatabase' import { saveStateToDatabase } from '../saveStateToDatabase'
import prisma from '@typebot.io/lib/prisma' import prisma from '@typebot.io/lib/prisma'
import { isDefined } from '@typebot.io/lib/utils' import { isDefined } from '@typebot.io/lib/utils'
import { Reply } from '../types'
type Props = { type Props = {
receivedMessage: WhatsAppIncomingMessage receivedMessage: WhatsAppIncomingMessage
@ -43,10 +44,6 @@ export const resumeWhatsAppFlow = async ({
const isPreview = workspaceId === undefined || credentialsId === undefined const isPreview = workspaceId === undefined || credentialsId === undefined
const { typebot } = session?.state.typebotsQueue[0] ?? {} const { typebot } = session?.state.typebotsQueue[0] ?? {}
const messageContent = await getIncomingMessageContent({
message: receivedMessage,
typebotId: typebot?.id,
})
const credentials = await getCredentials({ credentialsId, isPreview }) 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 = const isSessionExpired =
session && session &&
isDefined(session.state.expiryTimeout) && isDefined(session.state.expiryTimeout) &&
@ -64,13 +68,13 @@ export const resumeWhatsAppFlow = async ({
const resumeResponse = const resumeResponse =
session && !isSessionExpired session && !isSessionExpired
? await continueBotFlow(messageContent, { ? await continueBotFlow(reply, {
version: 2, version: 2,
state: { ...session.state, whatsApp: { contact } }, state: { ...session.state, whatsApp: { contact } },
}) })
: workspaceId : workspaceId
? await startWhatsAppSession({ ? await startWhatsAppSession({
incomingMessage: messageContent, incomingMessage: reply,
workspaceId, workspaceId,
credentials: { ...credentials, id: credentialsId as string }, credentials: { ...credentials, id: credentialsId as string },
contact, contact,
@ -128,10 +132,14 @@ export const resumeWhatsAppFlow = async ({
const getIncomingMessageContent = async ({ const getIncomingMessageContent = async ({
message, message,
typebotId, typebotId,
workspaceId,
accessToken,
}: { }: {
message: WhatsAppIncomingMessage message: WhatsAppIncomingMessage
typebotId?: string typebotId?: string
}): Promise<string | undefined> => { workspaceId?: string
accessToken: string
}): Promise<Reply> => {
switch (message.type) { switch (message.type) {
case 'text': case 'text':
return message.text.body return message.text.body
@ -151,10 +159,7 @@ const getIncomingMessageContent = async ({
if (message.type === 'audio') mediaId = message.audio.id if (message.type === 'audio') mediaId = message.audio.id
if (message.type === 'document') mediaId = message.document.id if (message.type === 'document') mediaId = message.document.id
if (!mediaId) return if (!mediaId) return
return ( return { type: 'whatsapp media', mediaId, workspaceId, accessToken }
env.NEXTAUTH_URL +
`/api/typebots/${typebotId}/whatsapp/media/${mediaId}`
)
case 'location': case 'location':
return `${message.location.latitude}, ${message.location.longitude}` return `${message.location.latitude}, ${message.location.longitude}`
} }

View File

@ -17,9 +17,10 @@ import {
ComparisonOperators, ComparisonOperators,
} from '@typebot.io/schemas/features/blocks/logic/condition/constants' } from '@typebot.io/schemas/features/blocks/logic/condition/constants'
import { VisitedEdge } from '@typebot.io/prisma' import { VisitedEdge } from '@typebot.io/prisma'
import { Reply } from '../types'
type Props = { type Props = {
incomingMessage?: string incomingMessage?: Reply
workspaceId: string workspaceId: string
credentials: WhatsAppCredentials['data'] & Pick<WhatsAppCredentials, 'id'> credentials: WhatsAppCredentials['data'] & Pick<WhatsAppCredentials, 'id'>
contact: NonNullable<SessionState['whatsApp']>['contact'] contact: NonNullable<SessionState['whatsApp']>['contact']
@ -104,10 +105,11 @@ export const startWhatsAppSession = async ({
} }
export const messageMatchStartCondition = ( export const messageMatchStartCondition = (
message: string, message: Reply,
startCondition: NonNullable<Settings['whatsApp']>['startCondition'] startCondition: NonNullable<Settings['whatsApp']>['startCondition']
) => { ) => {
if (!startCondition) return true if (!startCondition) return true
if (typeof message !== 'string') return false
return startCondition.logicalOperator === LogicalOperator.AND return startCondition.logicalOperator === LogicalOperator.AND
? startCondition.comparisons.every((comparison) => ? startCondition.comparisons.every((comparison) =>
matchComparison( matchComparison(

View File

@ -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<string> => {
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)
}

View File

@ -3,6 +3,7 @@ import { FileInputBlock } from './schema'
export const defaultFileInputOptions = { export const defaultFileInputOptions = {
isRequired: true, isRequired: true,
isMultipleAllowed: false, isMultipleAllowed: false,
visibility: 'Auto',
labels: { labels: {
placeholder: `<strong> placeholder: `<strong>
Click to upload Click to upload
@ -13,3 +14,5 @@ export const defaultFileInputOptions = {
skip: 'Skip', skip: 'Skip',
}, },
} as const satisfies FileInputBlock['options'] } as const satisfies FileInputBlock['options']
export const fileVisibilityOptions = ['Auto', 'Public', 'Private'] as const

View File

@ -1,6 +1,7 @@
import { z } from '../../../../zod' import { z } from '../../../../zod'
import { optionBaseSchema, blockBaseSchema } from '../../shared' import { optionBaseSchema, blockBaseSchema } from '../../shared'
import { InputBlockType } from '../constants' import { InputBlockType } from '../constants'
import { fileVisibilityOptions } from './constants'
const fileInputOptionsV5Schema = optionBaseSchema.merge( const fileInputOptionsV5Schema = optionBaseSchema.merge(
z.object({ z.object({
@ -15,6 +16,7 @@ const fileInputOptionsV5Schema = optionBaseSchema.merge(
}) })
.optional(), .optional(),
sizeLimit: z.number().optional(), sizeLimit: z.number().optional(),
visibility: z.enum(fileVisibilityOptions).optional(),
}) })
) )