⚡ (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:
@ -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)
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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'>
|
||||
}
|
||||
|
@ -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' }
|
||||
|
30
packages/bot-engine/whatsapp/downloadMedia.ts
Normal file
30
packages/bot-engine/whatsapp/downloadMedia.ts
Normal 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,
|
||||
}
|
||||
}
|
@ -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}`
|
||||
}
|
||||
|
@ -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(
|
||||
|
Reference in New Issue
Block a user