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

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

View File

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

View File

@ -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<SessionState, 'whatsApp' | 'expiryTimeout'>
}

View File

@ -18,6 +18,15 @@ export type ExecuteIntegrationResponse = {
customEmbedBubble?: CustomEmbedBubble
} & Pick<ContinueChatResponse, 'clientSideActions' | 'logs'>
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' }

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 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<string | undefined> => {
workspaceId?: string
accessToken: string
}): Promise<Reply> => {
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}`
}

View File

@ -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<WhatsAppCredentials, 'id'>
contact: NonNullable<SessionState['whatsApp']>['contact']
@ -104,10 +105,11 @@ export const startWhatsAppSession = async ({
}
export const messageMatchStartCondition = (
message: string,
message: Reply,
startCondition: NonNullable<Settings['whatsApp']>['startCondition']
) => {
if (!startCondition) return true
if (typeof message !== 'string') return false
return startCondition.logicalOperator === LogicalOperator.AND
? startCondition.comparisons.every((comparison) =>
matchComparison(