@@ -93,7 +93,8 @@
|
||||
"tinycolor2": "1.6.0",
|
||||
"trpc-openapi": "1.2.0",
|
||||
"unsplash-js": "^7.0.18",
|
||||
"use-debounce": "9.0.4"
|
||||
"use-debounce": "9.0.4",
|
||||
"@typebot.io/viewer": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/styled-system": "2.9.1",
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import got, { HTTPError } from 'got'
|
||||
import { getViewerUrl } from '@typebot.io/lib/getViewerUrl'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
|
||||
export const sendWhatsAppInitialMessage = authenticatedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
to: z.string(),
|
||||
typebotId: z.string(),
|
||||
startGroupId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({ input: { to, typebotId, startGroupId }, ctx: { user } }) => {
|
||||
const apiToken = await prisma.apiToken.findFirst({
|
||||
where: { ownerId: user.id },
|
||||
select: {
|
||||
token: true,
|
||||
},
|
||||
})
|
||||
if (!apiToken)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Api Token not found',
|
||||
})
|
||||
try {
|
||||
await got.post({
|
||||
method: 'POST',
|
||||
url: `${getViewerUrl()}/api/v1/typebots/${typebotId}/whatsapp/start-preview`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken.token}`,
|
||||
},
|
||||
json: { to, isPreview: true, startGroupId },
|
||||
})
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Request to viewer failed',
|
||||
cause: error instanceof HTTPError ? error.response.body : error,
|
||||
})
|
||||
}
|
||||
|
||||
return { message: 'success' }
|
||||
}
|
||||
)
|
||||
@@ -32,7 +32,7 @@ export const WhatsAppPreviewInstructions = (props: StackProps) => {
|
||||
const [hasMessageBeenSent, setHasMessageBeenSent] = useState(false)
|
||||
|
||||
const { showToast } = useToast()
|
||||
const { mutate } = trpc.sendWhatsAppInitialMessage.useMutation({
|
||||
const { mutate } = trpc.whatsApp.startWhatsAppPreview.useMutation({
|
||||
onMutate: () => setIsSendingMessage(true),
|
||||
onSettled: () => setIsSendingMessage(false),
|
||||
onError: (error) => showToast({ description: error.message }),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Typebot } from '@typebot.io/schemas'
|
||||
|
||||
export const isReadTypebotForbidden = async (
|
||||
typebot: Pick<Typebot, 'workspaceId'> & {
|
||||
collaborators: Pick<CollaboratorsOnTypebots, 'userId' | 'type'>[]
|
||||
collaborators: Pick<CollaboratorsOnTypebots, 'userId'>[]
|
||||
},
|
||||
user: Pick<User, 'email' | 'id'>
|
||||
) => {
|
||||
|
||||
44
apps/builder/src/features/whatsapp/receiveMessagePreview.ts
Normal file
44
apps/builder/src/features/whatsapp/receiveMessagePreview.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { whatsAppWebhookRequestBodySchema } from '@typebot.io/schemas/features/whatsapp'
|
||||
import { z } from 'zod'
|
||||
import { resumeWhatsAppFlow } from '@typebot.io/viewer/src/features/whatsApp/helpers/resumeWhatsAppFlow'
|
||||
import { isNotDefined } from '@typebot.io/lib'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
|
||||
export const receiveMessagePreview = publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/whatsapp/preview/webhook',
|
||||
summary: 'Message webhook',
|
||||
tags: ['WhatsApp'],
|
||||
},
|
||||
})
|
||||
.input(whatsAppWebhookRequestBodySchema)
|
||||
.output(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input: { entry } }) => {
|
||||
if (!env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID is not defined',
|
||||
})
|
||||
const receivedMessage = entry.at(0)?.changes.at(0)?.value.messages?.at(0)
|
||||
if (isNotDefined(receivedMessage)) return { message: 'No message found' }
|
||||
const contactName =
|
||||
entry.at(0)?.changes.at(0)?.value?.contacts?.at(0)?.profile?.name ?? ''
|
||||
const contactPhoneNumber = '+' + receivedMessage.from
|
||||
return resumeWhatsAppFlow({
|
||||
receivedMessage,
|
||||
sessionId: `wa-${receivedMessage.from}-preview`,
|
||||
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
|
||||
contact: {
|
||||
name: contactName,
|
||||
phoneNumber: contactPhoneNumber,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,16 @@ import { getPhoneNumber } from './getPhoneNumber'
|
||||
import { getSystemTokenInfo } from './getSystemTokenInfo'
|
||||
import { verifyIfPhoneNumberAvailable } from './verifyIfPhoneNumberAvailable'
|
||||
import { generateVerificationToken } from './generateVerificationToken'
|
||||
import { startWhatsAppPreview } from './startWhatsAppPreview'
|
||||
import { subscribePreviewWebhook } from './subscribePreviewWebhook'
|
||||
import { receiveMessagePreview } from './receiveMessagePreview'
|
||||
|
||||
export const whatsAppRouter = router({
|
||||
getPhoneNumber,
|
||||
getSystemTokenInfo,
|
||||
verifyIfPhoneNumberAvailable,
|
||||
generateVerificationToken,
|
||||
startWhatsAppPreview,
|
||||
subscribePreviewWebhook,
|
||||
receiveMessagePreview,
|
||||
})
|
||||
|
||||
164
apps/builder/src/features/whatsapp/startWhatsAppPreview.ts
Normal file
164
apps/builder/src/features/whatsapp/startWhatsAppPreview.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { authenticatedProcedure } from '@/helpers/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { sendWhatsAppMessage } from '@typebot.io/lib/whatsApp/sendWhatsAppMessage'
|
||||
import { startSession } from '@typebot.io/viewer/src/features/chat/helpers/startSession'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { HTTPError } from 'got'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { sendChatReplyToWhatsApp } from '@typebot.io/lib/whatsApp/sendChatReplyToWhatsApp'
|
||||
import { saveStateToDatabase } from '@typebot.io/viewer/src/features/chat/helpers/saveStateToDatabase'
|
||||
import { restartSession } from '@typebot.io/viewer/src/features/chat/queries/restartSession'
|
||||
import { isReadTypebotForbidden } from '../typebot/helpers/isReadTypebotForbidden'
|
||||
import { SessionState } from '@typebot.io/schemas'
|
||||
|
||||
export const startWhatsAppPreview = authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/typebots/{typebotId}/whatsapp/start-preview',
|
||||
summary: 'Start preview',
|
||||
tags: ['WhatsApp'],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
to: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value) => value.replace(/\s/g, '').replace(/\+/g, '')),
|
||||
typebotId: z.string(),
|
||||
startGroupId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
message: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(
|
||||
async ({ input: { to, typebotId, startGroupId }, ctx: { user } }) => {
|
||||
if (
|
||||
!env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID ||
|
||||
!env.META_SYSTEM_USER_TOKEN ||
|
||||
!env.WHATSAPP_PREVIEW_TEMPLATE_NAME
|
||||
)
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'Missing WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID or META_SYSTEM_USER_TOKEN or WHATSAPP_PREVIEW_TEMPLATE_NAME env variables',
|
||||
})
|
||||
|
||||
const existingTypebot = await prisma.typebot.findFirst({
|
||||
where: {
|
||||
id: typebotId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
workspaceId: true,
|
||||
collaborators: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (
|
||||
!existingTypebot?.id ||
|
||||
(await isReadTypebotForbidden(existingTypebot, user))
|
||||
)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Typebot not found' })
|
||||
|
||||
const sessionId = `wa-${to}-preview`
|
||||
|
||||
const existingSession = await prisma.chatSession.findFirst({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
select: {
|
||||
updatedAt: true,
|
||||
state: true,
|
||||
},
|
||||
})
|
||||
|
||||
// For users that did not interact with the bot in the last 24 hours, we need to send a template message.
|
||||
const canSendDirectMessagesToUser =
|
||||
(existingSession?.updatedAt.getTime() ?? 0) >
|
||||
Date.now() - 24 * 60 * 60 * 1000
|
||||
|
||||
const { newSessionState, messages, input, clientSideActions, logs } =
|
||||
await startSession({
|
||||
startParams: {
|
||||
isOnlyRegistering: !canSendDirectMessagesToUser,
|
||||
typebot: typebotId,
|
||||
isPreview: true,
|
||||
startGroupId,
|
||||
},
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
if (canSendDirectMessagesToUser) {
|
||||
await sendChatReplyToWhatsApp({
|
||||
to,
|
||||
typingEmulation: newSessionState.typingEmulation,
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
credentials: {
|
||||
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
|
||||
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
|
||||
},
|
||||
})
|
||||
await saveStateToDatabase({
|
||||
clientSideActions: [],
|
||||
input,
|
||||
logs,
|
||||
session: {
|
||||
id: sessionId,
|
||||
state: {
|
||||
...newSessionState,
|
||||
currentBlock: !input ? undefined : newSessionState.currentBlock,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await restartSession({
|
||||
state: {
|
||||
...newSessionState,
|
||||
whatsApp: (existingSession?.state as SessionState | undefined)
|
||||
?.whatsApp,
|
||||
},
|
||||
id: `wa-${to}-preview`,
|
||||
})
|
||||
try {
|
||||
await sendWhatsAppMessage({
|
||||
to,
|
||||
message: {
|
||||
type: 'template',
|
||||
template: {
|
||||
language: {
|
||||
code: env.WHATSAPP_PREVIEW_TEMPLATE_LANG,
|
||||
},
|
||||
name: env.WHATSAPP_PREVIEW_TEMPLATE_NAME,
|
||||
},
|
||||
},
|
||||
credentials: {
|
||||
phoneNumberId: env.WHATSAPP_PREVIEW_FROM_PHONE_NUMBER_ID,
|
||||
systemUserAccessToken: env.META_SYSTEM_USER_TOKEN,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof HTTPError) console.log(err.response.body)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Request to Meta to send preview message failed',
|
||||
cause: err,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
message: 'success',
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const subscribePreviewWebhook = publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
method: 'GET',
|
||||
path: '/whatsapp/preview/webhook',
|
||||
summary: 'Subscribe webhook',
|
||||
tags: ['WhatsApp'],
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
'hub.challenge': z.string(),
|
||||
'hub.verify_token': z.string(),
|
||||
})
|
||||
)
|
||||
.output(z.number())
|
||||
.query(
|
||||
async ({
|
||||
input: { 'hub.challenge': challenge, 'hub.verify_token': token },
|
||||
}) => {
|
||||
if (token !== env.ENCRYPTION_SECRET)
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
|
||||
return Number(challenge)
|
||||
}
|
||||
)
|
||||
@@ -3,7 +3,6 @@ import { webhookRouter } from '@/features/blocks/integrations/webhook/api/router
|
||||
import { getLinkedTypebots } from '@/features/blocks/logic/typebotLink/api/getLinkedTypebots'
|
||||
import { credentialsRouter } from '@/features/credentials/api/router'
|
||||
import { getAppVersionProcedure } from '@/features/dashboard/api/getAppVersionProcedure'
|
||||
import { sendWhatsAppInitialMessage } from '@/features/preview/api/sendWhatsAppInitialMessage'
|
||||
import { resultsRouter } from '@/features/results/api/router'
|
||||
import { processTelemetryEvent } from '@/features/telemetry/api/processTelemetryEvent'
|
||||
import { themeRouter } from '@/features/theme/api/router'
|
||||
@@ -23,7 +22,6 @@ export const trpcRouter = router({
|
||||
processTelemetryEvent,
|
||||
getLinkedTypebots,
|
||||
analytics: analyticsRouter,
|
||||
sendWhatsAppInitialMessage,
|
||||
workspace: workspaceRouter,
|
||||
typebot: typebotRouter,
|
||||
webhook: webhookRouter,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
"@/*": ["src/*", "../viewer/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
||||
|
||||
Reference in New Issue
Block a user