2
0

♻️ (webhook) Integrate webhook in typebot schema

Closes #313
This commit is contained in:
Baptiste Arnaud
2023-08-06 10:03:45 +02:00
parent 53e4bc2b75
commit fc25734689
66 changed files with 1501 additions and 876 deletions

View File

@@ -76,11 +76,40 @@ const nextConfig = {
}))
)
: []
).concat({
source: '/api/typebots/:typebotId/blocks/:blockId/storage/upload-url',
destination:
'/api/v1/typebots/:typebotId/blocks/:blockId/storage/upload-url',
}),
).concat([
{
source: '/api/typebots/:typebotId/blocks/:blockId/storage/upload-url',
destination:
'/api/v1/typebots/:typebotId/blocks/:blockId/storage/upload-url',
},
{
source:
'/api/typebots/:typebotId/blocks/:blockId/steps/:stepId/sampleResult',
destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/getResultExample`,
},
{
source: '/api/typebots/:typebotId/blocks/:blockId/sampleResult',
destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/getResultExample`,
},
{
source:
'/api/typebots/:typebotId/blocks/:blockId/steps/:stepId/unsubscribeWebhook',
destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/unsubscribe`,
},
{
source: '/api/typebots/:typebotId/blocks/:blockId/unsubscribeWebhook',
destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/unsubscribe`,
},
{
source:
'/api/typebots/:typebotId/blocks/:blockId/steps/:stepId/subscribeWebhook',
destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/subscribe`,
},
{
source: '/api/typebots/:typebotId/blocks/:blockId/subscribeWebhook',
destination: `${process.env.NEXTAUTH_URL}/api/v1/typebots/:typebotId/webhookBlocks/:blockId/subscribe`,
},
]),
}
},
}

View File

@@ -11,7 +11,6 @@ import {
WebhookResponse,
WebhookOptions,
defaultWebhookAttributes,
HttpMethod,
PublicTypebot,
KeyValue,
ReplyLog,
@@ -26,6 +25,7 @@ import { parseSampleResult } from './parseSampleResult'
import { ExecuteIntegrationResponse } from '@/features/chat/types'
import { parseVariables } from '@/features/variables/parseVariables'
import { resumeWebhookExecution } from './resumeWebhookExecution'
import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/enums'
type ParsedWebhook = ExecutableWebhook & {
basicAuth: { username?: string; password?: string }
@@ -38,9 +38,11 @@ export const executeWebhookBlock = async (
): Promise<ExecuteIntegrationResponse> => {
const { typebot, result } = state
const logs: ReplyLog[] = []
const webhook = (await prisma.webhook.findUnique({
where: { id: block.webhookId },
})) as Webhook | null
const webhook =
block.options.webhook ??
((await prisma.webhook.findUnique({
where: { id: block.webhookId },
})) as Webhook | null)
if (!webhook) {
logs.push({
status: 'error',

View File

@@ -1,6 +1,6 @@
import test, { expect } from '@playwright/test'
import { createId } from '@paralleldrive/cuid2'
import { HttpMethod } from '@typebot.io/schemas'
import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/enums'
import {
createWebhook,
importTypebotInDatabase,

View File

@@ -2,13 +2,14 @@ import { getTestAsset } from '@/test/utils/playwright'
import test, { expect } from '@playwright/test'
import { createId } from '@paralleldrive/cuid2'
import prisma from '@/lib/prisma'
import { HttpMethod, SendMessageInput } from '@typebot.io/schemas'
import { SendMessageInput } from '@typebot.io/schemas'
import {
createWebhook,
deleteTypebots,
deleteWebhooks,
importTypebotInDatabase,
} from '@typebot.io/lib/playwright/databaseActions'
import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/enums'
test.afterEach(async () => {
await deleteWebhooks(['chat-webhook-id'])

View File

@@ -162,7 +162,7 @@ const App = ({
return <NotFoundPage />
if (publishedTypebot.typebot.isClosed)
return <ErrorPage error={new Error('This bot is now closed')} />
return publishedTypebot.version === '3' ? (
return publishedTypebot.version ? (
<TypebotPageV3
url={props.url}
typebot={{

View File

@@ -9,7 +9,6 @@ import {
WebhookOptions,
WebhookResponse,
WebhookBlock,
HttpMethod,
} from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import got, { Method, Headers, HTTPError } from 'got'
@@ -25,6 +24,7 @@ import { fetchLinkedTypebots } from '@/features/blocks/logic/typebotLink/fetchLi
import { getPreviouslyLinkedTypebots } from '@/features/blocks/logic/typebotLink/getPreviouslyLinkedTypebots'
import { saveErrorLog } from '@/features/logs/saveErrorLog'
import { saveSuccessLog } from '@/features/logs/saveSuccessLog'
import { HttpMethod } from '@typebot.io/schemas/features/blocks/integrations/webhook/enums'
const cors = initMiddleware(Cors())
@@ -49,7 +49,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const block = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock
const webhook = typebot.webhooks.find(byId(block.webhookId))
const webhook =
block.options.webhook ?? typebot.webhooks.find(byId(block.webhookId))
if (!webhook)
return res
.status(404)
@@ -84,116 +85,131 @@ const checkIfBodyIsAVariable = (body: string) => /^{{.+}}$/.test(body)
export const executeWebhook =
(typebot: Typebot) =>
async ({
webhook,
variables,
groupId,
resultValues,
resultId,
parentTypebotIds = [],
}: {
webhook: Webhook
variables: Variable[]
groupId: string
resultValues?: ResultValues
resultId?: string
parentTypebotIds: string[]
}): Promise<WebhookResponse> => {
if (!webhook.url || !webhook.method)
return {
statusCode: 400,
data: { message: `Webhook doesn't have url or method` },
}
const basicAuth: { username?: string; password?: string } = {}
const basicAuthHeaderIdx = webhook.headers.findIndex(
(h) =>
h.key?.toLowerCase() === 'authorization' &&
h.value?.toLowerCase()?.includes('basic')
)
const isUsernamePasswordBasicAuth =
basicAuthHeaderIdx !== -1 &&
webhook.headers[basicAuthHeaderIdx].value?.includes(':')
if (isUsernamePasswordBasicAuth) {
const [username, password] =
webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? []
basicAuth.username = username
basicAuth.password = password
webhook.headers.splice(basicAuthHeaderIdx, 1)
}
const headers = convertKeyValueTableToObject(webhook.headers, variables) as
| Headers
| undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, variables)
)
const contentType = headers ? headers['Content-Type'] : undefined
const linkedTypebotsParents = await fetchLinkedTypebots({
isPreview: !('typebotId' in typebot),
typebotIds: parentTypebotIds,
})
const linkedTypebotsChildren = await getPreviouslyLinkedTypebots({
isPreview: !('typebotId' in typebot),
typebots: [typebot],
})([])
const bodyContent = await getBodyContent(typebot, [
...linkedTypebotsParents,
...linkedTypebotsChildren,
])({
body: webhook.body,
resultValues,
groupId,
async ({
webhook,
variables,
})
const { data: body, isJson } =
bodyContent && webhook.method !== HttpMethod.GET
? safeJsonParse(
groupId,
resultValues,
resultId,
parentTypebotIds = [],
}: {
webhook: Webhook
variables: Variable[]
groupId: string
resultValues?: ResultValues
resultId?: string
parentTypebotIds: string[]
}): Promise<WebhookResponse> => {
if (!webhook.url || !webhook.method)
return {
statusCode: 400,
data: { message: `Webhook doesn't have url or method` },
}
const basicAuth: { username?: string; password?: string } = {}
const basicAuthHeaderIdx = webhook.headers.findIndex(
(h) =>
h.key?.toLowerCase() === 'authorization' &&
h.value?.toLowerCase()?.includes('basic')
)
const isUsernamePasswordBasicAuth =
basicAuthHeaderIdx !== -1 &&
webhook.headers[basicAuthHeaderIdx].value?.includes(':')
if (isUsernamePasswordBasicAuth) {
const [username, password] =
webhook.headers[basicAuthHeaderIdx].value?.slice(6).split(':') ?? []
basicAuth.username = username
basicAuth.password = password
webhook.headers.splice(basicAuthHeaderIdx, 1)
}
const headers = convertKeyValueTableToObject(webhook.headers, variables) as
| Headers
| undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, variables)
)
const contentType = headers ? headers['Content-Type'] : undefined
const linkedTypebotsParents = await fetchLinkedTypebots({
isPreview: !('typebotId' in typebot),
typebotIds: parentTypebotIds,
})
const linkedTypebotsChildren = await getPreviouslyLinkedTypebots({
isPreview: !('typebotId' in typebot),
typebots: [typebot],
})([])
const bodyContent = await getBodyContent(typebot, [
...linkedTypebotsParents,
...linkedTypebotsChildren,
])({
body: webhook.body,
resultValues,
groupId,
variables,
})
const { data: body, isJson } =
bodyContent && webhook.method !== HttpMethod.GET
? safeJsonParse(
parseVariables(variables, {
escapeForJson: !checkIfBodyIsAVariable(bodyContent),
})(bodyContent)
)
: { data: undefined, isJson: false }
: { data: undefined, isJson: false }
const request = {
url: parseVariables(variables)(
webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
),
method: webhook.method as Method,
headers,
...basicAuth,
json:
!contentType?.includes('x-www-form-urlencoded') && body && isJson
? body
: undefined,
form:
contentType?.includes('x-www-form-urlencoded') && body
? body
: undefined,
body: body && !isJson ? body : undefined,
}
try {
const response = await got(request.url, omit(request, 'url'))
await saveSuccessLog({
resultId,
message: 'Webhook successfuly executed.',
details: {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
},
})
return {
statusCode: response.statusCode,
data: safeJsonParse(response.body).data,
const request = {
url: parseVariables(variables)(
webhook.url + (queryParams !== '' ? `?${queryParams}` : '')
),
method: webhook.method as Method,
headers,
...basicAuth,
json:
!contentType?.includes('x-www-form-urlencoded') && body && isJson
? body
: undefined,
form:
contentType?.includes('x-www-form-urlencoded') && body
? body
: undefined,
body: body && !isJson ? body : undefined,
}
} catch (error) {
if (error instanceof HTTPError) {
const response = {
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
try {
const response = await got(request.url, omit(request, 'url'))
await saveSuccessLog({
resultId,
message: 'Webhook successfuly executed.',
details: {
statusCode: response.statusCode,
request,
response: safeJsonParse(response.body).data,
},
})
return {
statusCode: response.statusCode,
data: safeJsonParse(response.body).data,
}
} catch (error) {
if (error instanceof HTTPError) {
const response = {
statusCode: error.response.statusCode,
data: safeJsonParse(error.response.body as string).data,
}
await saveErrorLog({
resultId,
message: 'Webhook returned an error',
details: {
request,
response,
},
})
return response
}
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
await saveErrorLog({
resultId,
message: 'Webhook returned an error',
message: 'Webhook failed to execute',
details: {
request,
response,
@@ -201,51 +217,36 @@ export const executeWebhook =
})
return response
}
const response = {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
console.error(error)
await saveErrorLog({
resultId,
message: 'Webhook failed to execute',
details: {
request,
response,
},
})
return response
}
}
const getBodyContent =
(
typebot: Pick<Typebot | PublicTypebot, 'groups' | 'variables' | 'edges'>,
linkedTypebots: (Typebot | PublicTypebot)[]
) =>
async ({
body,
resultValues,
groupId,
variables,
}: {
body?: string | null
resultValues?: ResultValues
groupId: string
variables: Variable[]
}): Promise<string | undefined> => {
if (!body) return
return body === '{{state}}'
? JSON.stringify(
async ({
body,
resultValues,
groupId,
variables,
}: {
body?: string | null
resultValues?: ResultValues
groupId: string
variables: Variable[]
}): Promise<string | undefined> => {
if (!body) return
return body === '{{state}}'
? JSON.stringify(
resultValues
? parseAnswers(typebot, linkedTypebots)(resultValues)
: await parseSampleResult(typebot, linkedTypebots)(
groupId,
variables
)
groupId,
variables
)
)
: body
}
: body
}
const convertKeyValueTableToObject = (
keyValues: KeyValue[] | undefined,

View File

@@ -1,38 +0,0 @@
import { authenticateUser } from '@/helpers/authenticateUser'
import prisma from '@/lib/prisma'
import { Typebot } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from '@typebot.io/lib/api'
import { getPreviouslyLinkedTypebots } from '@/features/blocks/logic/typebotLink/getPreviouslyLinkedTypebots'
import { parseSampleResult } from '@/features/blocks/integrations/webhook/parseSampleResult'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'GET') {
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
const block = typebot.groups
.flatMap((g) => g.blocks)
.find((s) => s.id === blockId)
if (!block) return res.status(404).send({ message: 'Group not found' })
const linkedTypebots = await getPreviouslyLinkedTypebots({
isPreview: true,
typebots: [typebot],
user,
})([])
return res.send(
await parseSampleResult(typebot, linkedTypebots)(block.groupId, [])
)
}
methodNotAllowed(res)
}
export default handler

View File

@@ -1,72 +0,0 @@
import prisma from '@/lib/prisma'
import {
defaultWebhookAttributes,
ResultValues,
Typebot,
Variable,
Webhook,
WebhookOptions,
WebhookBlock,
} from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { initMiddleware, methodNotAllowed, notFound } from '@typebot.io/lib/api'
import { byId } from '@typebot.io/lib'
import Cors from 'cors'
import { executeWebhook } from '../../executeWebhook'
const cors = initMiddleware(Cors())
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await cors(req, res)
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const blockId = req.query.blockId as string
const resultId = req.query.resultId as string | undefined
const { resultValues, variables, parentTypebotIds } = (
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
) as {
resultValues: ResultValues
variables: Variable[]
parentTypebotIds: string[]
}
const typebot = (await prisma.typebot.findUnique({
where: { id: typebotId },
include: { webhooks: true },
})) as unknown as (Typebot & { webhooks: Webhook[] }) | null
if (!typebot) return notFound(res)
const block = typebot.groups
.find(byId(groupId))
?.blocks.find(byId(blockId)) as WebhookBlock
const webhook = typebot.webhooks.find(byId(block.webhookId))
if (!webhook)
return res
.status(404)
.send({ statusCode: 404, data: { message: `Couldn't find webhook` } })
const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const result = await executeWebhook(typebot)({
webhook: preparedWebhook,
variables,
groupId,
resultValues,
resultId,
parentTypebotIds,
})
return res.status(200).send(result)
}
return methodNotAllowed(res)
}
const prepareWebhookAttributes = (
webhook: Webhook,
options: WebhookOptions
): Webhook => {
if (options.isAdvancedConfig === false) {
return { ...webhook, body: '{{state}}', ...defaultWebhookAttributes }
} else if (options.isCustomBody === false) {
return { ...webhook, body: '{{state}}' }
}
return webhook
}
export default handler

View File

@@ -1,34 +0,0 @@
import prisma from '@/lib/prisma'
import { Typebot } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from '@typebot.io/lib/api'
import { parseSampleResult } from '@/features/blocks/integrations/webhook/parseSampleResult'
import { getPreviouslyLinkedTypebots } from '@/features/blocks/logic/typebotLink/getPreviouslyLinkedTypebots'
import { authenticateUser } from '@/helpers/authenticateUser'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'GET') {
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
const linkedTypebots = await getPreviouslyLinkedTypebots({
isPreview: true,
typebots: [typebot],
user,
})([])
return res.send(
await parseSampleResult(typebot, linkedTypebots)(groupId, [])
)
}
methodNotAllowed(res)
}
export default handler

View File

@@ -1,53 +0,0 @@
import { Typebot, WebhookBlock } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from '@typebot.io/lib/api'
import { byId } from '@typebot.io/lib'
import prisma from '@/lib/prisma'
import { authenticateUser } from '@/helpers/authenticateUser'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const body = req.body as Record<string, string>
if (!('url' in body))
return res.status(403).send({ message: 'url is missing in body' })
const { url } = body
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.find(byId(groupId))
?.blocks.find(byId(blockId)) as WebhookBlock
await prisma.webhook.upsert({
where: { id: webhookId },
update: { url, body: '{{state}}', method: 'POST' },
create: {
url,
body: '{{state}}',
method: 'POST',
typebotId,
headers: [],
queryParams: [],
},
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "blockId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default handler

View File

@@ -1,41 +0,0 @@
import { Typebot, WebhookBlock } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from '@typebot.io/lib/api'
import { byId } from '@typebot.io/lib'
import { authenticateUser } from '@/helpers/authenticateUser'
import prisma from '@/lib/prisma'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const groupId = req.query.groupId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.find(byId(groupId))
?.blocks.find(byId(blockId)) as WebhookBlock
await prisma.webhook.updateMany({
where: { id: webhookId },
data: { url: null },
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "blockId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default handler

View File

@@ -1,52 +0,0 @@
import { authenticateUser } from '@/helpers/authenticateUser'
import prisma from '@/lib/prisma'
import { Typebot, WebhookBlock } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { byId } from '@typebot.io/lib'
import { methodNotAllowed } from '@typebot.io/lib/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const body = req.body as Record<string, string>
if (!('url' in body))
return res.status(403).send({ message: 'url is missing in body' })
const { url } = body
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock
await prisma.webhook.upsert({
where: { id: webhookId },
update: { url, body: '{{state}}', method: 'POST' },
create: {
url,
body: '{{state}}',
method: 'POST',
typebotId,
headers: [],
queryParams: [],
},
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "groupId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default handler

View File

@@ -1,40 +0,0 @@
import { authenticateUser } from '@/helpers/authenticateUser'
import prisma from '@/lib/prisma'
import { Typebot, WebhookBlock } from '@typebot.io/schemas'
import { NextApiRequest, NextApiResponse } from 'next'
import { byId } from '@typebot.io/lib'
import { methodNotAllowed } from '@typebot.io/lib/api'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await authenticateUser(req)
if (!user) return res.status(401).json({ message: 'Not authenticated' })
if (req.method === 'POST') {
const typebotId = req.query.typebotId as string
const blockId = req.query.blockId as string
const typebot = (await prisma.typebot.findFirst({
where: {
id: typebotId,
workspace: { members: { some: { userId: user.id } } },
},
})) as unknown as Typebot | undefined
if (!typebot) return res.status(400).send({ message: 'Typebot not found' })
try {
const { webhookId } = typebot.groups
.flatMap((g) => g.blocks)
.find(byId(blockId)) as WebhookBlock
await prisma.webhook.updateMany({
where: { id: webhookId },
data: { url: null },
})
return res.send({ message: 'success' })
} catch (err) {
return res
.status(400)
.send({ message: "groupId doesn't point to a Webhook block" })
}
}
return methodNotAllowed(res)
}
export default handler