⚡ (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:
@ -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 (
|
||||
<Stack spacing={4}>
|
||||
<SwitchWithLabel
|
||||
@ -91,6 +99,13 @@ export const FileInputSettings = ({ options, onOptionsChange }: Props) => {
|
||||
onChange={updateSkipButtonLabel}
|
||||
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>
|
||||
<FormLabel mb="0" htmlFor="variable">
|
||||
{options?.isMultipleAllowed
|
||||
|
@ -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
|
@ -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)
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,7 +179,10 @@ export const generateUploadUrl = publicProcedure
|
||||
return {
|
||||
presignedUrl: presignedPostPolicy.postURL,
|
||||
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}`
|
||||
: `${presignedPostPolicy.postURL}/${presignedPostPolicy.formData.key}`,
|
||||
}
|
||||
|
@ -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(
|
||||
|
27
packages/lib/s3/getFileTempUrl.ts
Normal file
27
packages/lib/s3/getFileTempUrl.ts
Normal 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)
|
||||
}
|
@ -3,6 +3,7 @@ import { FileInputBlock } from './schema'
|
||||
export const defaultFileInputOptions = {
|
||||
isRequired: true,
|
||||
isMultipleAllowed: false,
|
||||
visibility: 'Auto',
|
||||
labels: {
|
||||
placeholder: `<strong>
|
||||
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
|
||||
|
@ -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(),
|
||||
})
|
||||
)
|
||||
|
||||
|
Reference in New Issue
Block a user