2
0

Introduce a new high-performing standalone chat API (#1200)

Closes #1154

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
	- Added authentication functionality for user sessions in chat API.
- Introduced chat-related API endpoints for starting, previewing, and
continuing chat sessions, and streaming messages.
- Implemented WhatsApp API webhook handling for receiving and processing
messages.
- Added environment variable `NEXT_PUBLIC_CHAT_API_URL` for chat API URL
configuration.

- **Bug Fixes**
	- Adjusted file upload logic to correctly determine the API host.
	- Fixed message streaming URL in chat integration with OpenAI.

- **Documentation**
- Updated guides for creating blocks, local installation, self-hosting,
and deployment to use `bun` instead of `pnpm`.

- **Refactor**
	- Refactored chat API functionalities to use modular architecture.
- Simplified client log saving and session update functionalities by
using external functions.
	- Transitioned package management and workflow commands to use `bun`.

- **Chores**
- Switched to `bun` for package management in Dockerfiles and GitHub
workflows.
	- Added new Dockerfile for chat API service setup with Bun framework.
	- Updated `.prettierignore` and documentation with new commands.

- **Style**
	- No visible changes to end-users.

- **Tests**
	- No visible changes to end-users.

- **Revert**
	- No reverts in this release.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Baptiste Arnaud
2024-03-21 10:23:23 +01:00
committed by GitHub
parent 5b9176708c
commit 2fcf83c529
51 changed files with 1446 additions and 494 deletions

View File

@ -0,0 +1,102 @@
import { TRPCError } from '@trpc/server'
import { isDefined, isNotDefined } from '@typebot.io/lib/utils'
import { getSession } from '../queries/getSession'
import { continueBotFlow } from '../continueBotFlow'
import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs'
import { parseDynamicTheme } from '../parseDynamicTheme'
import { saveStateToDatabase } from '../saveStateToDatabase'
import { computeCurrentProgress } from '../computeCurrentProgress'
type Props = {
origin: string | undefined
message?: string
sessionId: string
}
export const continueChat = async ({ origin, sessionId, message }: Props) => {
const session = await getSession(sessionId)
if (!session) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Session not found.',
})
}
const isSessionExpired =
session &&
isDefined(session.state.expiryTimeout) &&
session.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
if (isSessionExpired)
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Session expired. You need to start a new session.',
})
let corsOrigin
if (
session?.state.allowedOrigins &&
session.state.allowedOrigins.length > 0
) {
if (origin && session.state.allowedOrigins.includes(origin))
corsOrigin = origin
else corsOrigin = session.state.allowedOrigins[0]
}
const {
messages,
input,
clientSideActions,
newSessionState,
logs,
lastMessageNewFormat,
visitedEdges,
} = await continueBotFlow(message, {
version: 2,
state: session.state,
startTime: Date.now(),
})
if (newSessionState)
await saveStateToDatabase({
session: {
id: session.id,
state: newSessionState,
},
input,
logs,
clientSideActions,
visitedEdges,
hasCustomEmbedBubble: messages.some(
(message) => message.type === 'custom-embed'
),
})
const isPreview = isNotDefined(session.state.typebotsQueue[0].resultId)
const isEnded =
newSessionState.progressMetadata &&
!input?.id &&
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? 0) ===
0
return {
messages,
input,
clientSideActions,
dynamicTheme: parseDynamicTheme(newSessionState),
logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs),
lastMessageNewFormat,
corsOrigin,
progress: newSessionState.progressMetadata
? isEnded
? 100
: computeCurrentProgress({
typebotsQueue: newSessionState.typebotsQueue,
progressMetadata: newSessionState.progressMetadata,
currentInputBlockId: input?.id,
})
: undefined,
}
}

View File

@ -0,0 +1,130 @@
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { OpenAI } from 'openai'
import { decryptV2 } from '@typebot.io/lib/api/encryption/decryptV2'
import { forgedBlocks } from '@typebot.io/forge-repository/definitions'
import { ReadOnlyVariableStore } from '@typebot.io/forge'
import {
ParseVariablesOptions,
parseVariables,
} from '@typebot.io/variables/parseVariables'
import { getOpenAIChatCompletionStream } from './legacy/getOpenAIChatCompletionStream'
import { getCredentials } from '../queries/getCredentials'
import { getSession } from '../queries/getSession'
import { getBlockById } from '@typebot.io/schemas/helpers'
import { isForgedBlockType } from '@typebot.io/schemas/features/blocks/forged/helpers'
type Props = {
sessionId: string
messages: OpenAI.Chat.ChatCompletionMessage[] | undefined
}
export const getMessageStream = async ({ sessionId, messages }: Props) => {
const session = await getSession(sessionId)
if (!session?.state || !session.state.currentBlockId)
return { status: 404, message: 'Could not find session' }
const { group, block } = getBlockById(
session.state.currentBlockId,
session.state.typebotsQueue[0].typebot.groups
)
if (!block || !group)
return {
status: 404,
message: 'Could not find block or group',
}
if (!('options' in block))
return {
status: 400,
message: 'This block does not have options',
}
if (block.type === IntegrationBlockType.OPEN_AI && messages) {
try {
const stream = await getOpenAIChatCompletionStream(
session.state,
block.options as ChatCompletionOpenAIOptions,
messages
)
if (!stream)
return {
status: 500,
message: 'Could not create stream',
}
return { stream }
} catch (error) {
if (error instanceof OpenAI.APIError) {
const { message } = error
return {
status: 500,
message,
}
} else {
throw error
}
}
}
if (!isForgedBlockType(block.type))
return {
status: 400,
message: 'This block does not have a stream function',
}
const blockDef = forgedBlocks[block.type]
const action = blockDef?.actions.find((a) => a.name === block.options?.action)
if (!action || !action.run?.stream)
return {
status: 400,
message: 'This block does not have a stream function',
}
try {
if (!block.options.credentialsId)
return { status: 404, message: 'Could not find credentials' }
const credentials = await getCredentials(block.options.credentialsId)
if (!credentials)
return { status: 404, message: 'Could not find credentials' }
const decryptedCredentials = await decryptV2(
credentials.data,
credentials.iv
)
const variables: ReadOnlyVariableStore = {
list: () => session.state.typebotsQueue[0].typebot.variables,
get: (id: string) => {
const variable = session.state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === id
)
return variable?.value
},
parse: (text: string, params?: ParseVariablesOptions) =>
parseVariables(
session.state.typebotsQueue[0].typebot.variables,
params
)(text),
}
const stream = await action.run.stream.run({
credentials: decryptedCredentials,
options: block.options,
variables,
})
if (!stream) return { status: 500, message: 'Could not create stream' }
return { stream }
} catch (error) {
if (error instanceof OpenAI.APIError) {
const { message } = error
return {
status: 500,
message,
}
}
return {
status: 500,
message: 'Could not create stream',
}
}
}

View File

@ -0,0 +1,58 @@
import { decryptV2 } from '@typebot.io/lib/api/encryption/decryptV2'
import { isNotEmpty } from '@typebot.io/lib/utils'
import {
ChatCompletionOpenAIOptions,
OpenAICredentials,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
import { OpenAIStream } from 'ai'
import { parseVariableNumber } from '@typebot.io/variables/parseVariableNumber'
import { ClientOptions, OpenAI } from 'openai'
import { defaultOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai/constants'
import { getCredentials } from '../../queries/getCredentials'
export const getOpenAIChatCompletionStream = async (
state: SessionState,
options: ChatCompletionOpenAIOptions,
messages: OpenAI.Chat.ChatCompletionMessageParam[]
) => {
if (!options.credentialsId) return
const credentials = await getCredentials(options.credentialsId)
if (!credentials) {
console.error('Could not find credentials in database')
return
}
const { apiKey } = (await decryptV2(
credentials.data,
credentials.iv
)) as OpenAICredentials['data']
const { typebot } = state.typebotsQueue[0]
const temperature = parseVariableNumber(typebot.variables)(
options.advancedSettings?.temperature
)
const config = {
apiKey,
baseURL: options.baseUrl,
defaultHeaders: {
'api-key': apiKey,
},
defaultQuery: isNotEmpty(options.apiVersion)
? {
'api-version': options.apiVersion,
}
: undefined,
} satisfies ClientOptions
const openai = new OpenAI(config)
const response = await openai.chat.completions.create({
model: options.model ?? defaultOpenAIOptions.model,
temperature,
stream: true,
messages,
})
return OpenAIStream(response)
}

View File

@ -0,0 +1,35 @@
import { WhatsAppWebhookRequestBody } from '@typebot.io/schemas/features/whatsapp'
import { isNotDefined } from '@typebot.io/lib'
import { resumeWhatsAppFlow } from '../whatsapp/resumeWhatsAppFlow'
type Props = {
entry: WhatsAppWebhookRequestBody['entry']
credentialsId: string
workspaceId: string
}
export const receiveMessage = async ({
entry,
credentialsId,
workspaceId,
}: Props) => {
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 =
entry.at(0)?.changes.at(0)?.value?.messages?.at(0)?.from ?? ''
const phoneNumberId = entry.at(0)?.changes.at(0)?.value
.metadata.phone_number_id
if (!phoneNumberId) return { message: 'No phone number id found' }
return resumeWhatsAppFlow({
receivedMessage,
sessionId: `wa-${phoneNumberId}-${receivedMessage.from}`,
credentialsId,
workspaceId,
contact: {
name: contactName,
phoneNumber: contactPhoneNumber,
},
})
}

View File

@ -0,0 +1,31 @@
import { WhatsAppWebhookRequestBody } from '@typebot.io/schemas/features/whatsapp'
import { isNotDefined } from '@typebot.io/lib'
import { TRPCError } from '@trpc/server'
import { env } from '@typebot.io/env'
import { resumeWhatsAppFlow } from '../whatsapp/resumeWhatsAppFlow'
type Props = {
entry: WhatsAppWebhookRequestBody['entry']
}
export const receiveMessagePreview = ({ entry }: Props) => {
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 =
entry.at(0)?.changes.at(0)?.value?.messages?.at(0)?.from ?? ''
return resumeWhatsAppFlow({
receivedMessage,
sessionId: `wa-preview-${receivedMessage.from}`,
contact: {
name: contactName,
phoneNumber: contactPhoneNumber,
},
})
}

View File

@ -0,0 +1,49 @@
import { TRPCError } from '@trpc/server'
import { ChatLog } from '@typebot.io/schemas'
import { formatLogDetails } from '../logs/helpers/formatLogDetails'
import { getSession } from '../queries/getSession'
import { saveLogs } from '../queries/saveLogs'
type Props = {
sessionId: string
clientLogs: ChatLog[]
}
export const saveClientLogs = async ({ sessionId, clientLogs }: Props) => {
const session = await getSession(sessionId)
if (!session) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Session not found.',
})
}
const resultId = session.state.typebotsQueue[0].resultId
if (!resultId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Result not found.',
})
}
try {
await saveLogs(
clientLogs.map((log) => ({
...log,
resultId,
details: formatLogDetails(log.details),
}))
)
return {
message: 'Logs successfully saved.',
}
} catch (e) {
console.error('Failed to save logs', e)
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to save logs.',
})
}
}

View File

@ -0,0 +1,107 @@
import { computeCurrentProgress } from '../computeCurrentProgress'
import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs'
import { restartSession } from '../queries/restartSession'
import { saveStateToDatabase } from '../saveStateToDatabase'
import { startSession } from '../startSession'
type Props = {
origin: string | undefined
message?: string
isOnlyRegistering: boolean
publicId: string
isStreamEnabled: boolean
prefilledVariables?: Record<string, unknown>
resultId?: string
}
export const startChat = async ({
origin,
message,
isOnlyRegistering,
publicId,
isStreamEnabled,
prefilledVariables,
resultId: startResultId,
}: Props) => {
const {
typebot,
messages,
input,
resultId,
dynamicTheme,
logs,
clientSideActions,
newSessionState,
visitedEdges,
} = await startSession({
version: 2,
startParams: {
type: 'live',
isOnlyRegistering,
isStreamEnabled,
publicId,
prefilledVariables,
resultId: startResultId,
},
message,
})
let corsOrigin
if (
newSessionState.allowedOrigins &&
newSessionState.allowedOrigins.length > 0
) {
if (origin && newSessionState.allowedOrigins.includes(origin))
corsOrigin = origin
else corsOrigin = newSessionState.allowedOrigins[0]
}
const session = isOnlyRegistering
? await restartSession({
state: newSessionState,
})
: await saveStateToDatabase({
session: {
state: newSessionState,
},
input,
logs,
clientSideActions,
visitedEdges,
hasCustomEmbedBubble: messages.some(
(message) => message.type === 'custom-embed'
),
})
const isEnded =
newSessionState.progressMetadata &&
!input?.id &&
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? 0) ===
0
return {
sessionId: session.id,
typebot: {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
},
messages,
input,
resultId,
dynamicTheme,
logs: logs?.filter(filterPotentiallySensitiveLogs),
clientSideActions,
corsOrigin,
progress: newSessionState.progressMetadata
? isEnded
? 100
: computeCurrentProgress({
typebotsQueue: newSessionState.typebotsQueue,
progressMetadata: newSessionState.progressMetadata,
currentInputBlockId: input?.id,
})
: undefined,
}
}

View File

@ -0,0 +1,97 @@
import { StartFrom, StartTypebot } from '@typebot.io/schemas'
import { restartSession } from '../queries/restartSession'
import { saveStateToDatabase } from '../saveStateToDatabase'
import { startSession } from '../startSession'
import { computeCurrentProgress } from '../computeCurrentProgress'
type Props = {
message?: string
isOnlyRegistering: boolean
isStreamEnabled: boolean
startFrom?: StartFrom
typebotId: string
typebot?: StartTypebot
userId?: string
prefilledVariables?: Record<string, unknown>
}
export const startChatPreview = async ({
message,
isOnlyRegistering,
isStreamEnabled,
startFrom,
typebotId,
typebot: startTypebot,
userId,
prefilledVariables,
}: Props) => {
const {
typebot,
messages,
input,
dynamicTheme,
logs,
clientSideActions,
newSessionState,
visitedEdges,
} = await startSession({
version: 2,
startParams: {
type: 'preview',
isOnlyRegistering,
isStreamEnabled,
startFrom,
typebotId,
typebot: startTypebot,
userId,
prefilledVariables,
},
message,
})
const session = isOnlyRegistering
? await restartSession({
state: newSessionState,
})
: await saveStateToDatabase({
session: {
state: newSessionState,
},
input,
logs,
clientSideActions,
visitedEdges,
hasCustomEmbedBubble: messages.some(
(message) => message.type === 'custom-embed'
),
})
const isEnded =
newSessionState.progressMetadata &&
!input?.id &&
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ?? 0) ===
0
return {
sessionId: session.id,
typebot: {
id: typebot.id,
theme: typebot.theme,
settings: typebot.settings,
},
messages,
input,
dynamicTheme,
logs,
clientSideActions,
progress: newSessionState.progressMetadata
? isEnded
? 100
: computeCurrentProgress({
typebotsQueue: newSessionState.typebotsQueue,
progressMetadata: newSessionState.progressMetadata,
currentInputBlockId: input?.id,
})
: undefined,
}
}

View File

@ -0,0 +1,97 @@
import { TRPCError } from '@trpc/server'
import prisma from '@typebot.io/lib/prisma'
import {
SessionState,
Variable,
PublicTypebot,
Typebot,
} from '@typebot.io/schemas'
import { getSession } from '../queries/getSession'
type Props = {
user?: { id: string }
sessionId: string
}
export const updateTypebotInSession = async ({ user, sessionId }: Props) => {
if (!user)
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
const session = await getSession(sessionId)
if (!session)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' })
const publicTypebot = (await prisma.publicTypebot.findFirst({
where: {
typebot: {
id: session.state.typebotsQueue[0].typebot.id,
OR: [
{
workspace: {
members: {
some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } },
},
},
},
{
collaborators: {
some: { userId: user.id, type: { in: ['WRITE'] } },
},
},
],
},
},
select: {
edges: true,
groups: true,
variables: true,
},
})) as Pick<PublicTypebot, 'edges' | 'variables' | 'groups'> | null
if (!publicTypebot)
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
const newSessionState = updateSessionState(session.state, publicTypebot)
await prisma.chatSession.updateMany({
where: { id: session.id },
data: { state: newSessionState },
})
return { message: 'success' } as const
}
const updateSessionState = (
currentState: SessionState,
newTypebot: Pick<PublicTypebot, 'edges' | 'variables' | 'groups'>
): SessionState => ({
...currentState,
typebotsQueue: currentState.typebotsQueue.map((typebotInQueue, index) =>
index === 0
? {
...typebotInQueue,
typebot: {
...typebotInQueue.typebot,
edges: newTypebot.edges,
groups: newTypebot.groups,
variables: updateVariablesInSession(
typebotInQueue.typebot.variables,
newTypebot.variables
),
},
}
: typebotInQueue
) as SessionState['typebotsQueue'],
})
const updateVariablesInSession = (
currentVariables: Variable[],
newVariables: Typebot['variables']
): Variable[] => [
...currentVariables,
...newVariables.filter(
(newVariable) =>
!currentVariables.find(
(currentVariable) => currentVariable.id === newVariable.id
)
),
]

View File

@ -84,14 +84,13 @@ export const createChatCompletionOpenAI = async (
)?.name
if (
isPlaneteScale() &&
isCredentialsV2(credentials) &&
newSessionState.isStreamEnabled &&
!newSessionState.whatsApp &&
isNextBubbleMessageWithAssistantMessage(typebot)(
blockId,
assistantMessageVariableName
)
) &&
!process.env.VERCEL_ENV
) {
return {
clientSideActions: [

View File

@ -7,9 +7,9 @@ import {
import got from 'got'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
import { byId, isDefined, isEmpty } from '@typebot.io/lib'
import prisma from '@typebot.io/lib/prisma'
import { ExecuteIntegrationResponse } from '../../../types'
import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesInSession'
import { getCredentials } from '../../../queries/getCredentials'
import { parseAnswers } from '@typebot.io/results/parseAnswers'
const URL = 'https://api.zemantic.ai/v1/search-documents'
@ -25,11 +25,7 @@ export const executeZemanticAiBlock = async (
outgoingEdgeId: block.outgoingEdgeId,
}
const credentials = await prisma.credentials.findUnique({
where: {
id: block.options?.credentialsId,
},
})
const credentials = await getCredentials(block.options.credentialsId)
if (!credentials) {
return {

View File

@ -19,6 +19,8 @@ import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesI
import { ExecuteIntegrationResponse } from '../types'
import { byId } from '@typebot.io/lib'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { env } from '@typebot.io/env'
import { getCredentials } from '../queries/getCredentials'
export const executeForgedBlock = async (
state: SessionState,
@ -40,11 +42,7 @@ export const executeForgedBlock = async (
logs: [noCredentialsError],
}
}
credentials = await prisma.credentials.findUnique({
where: {
id: block.options.credentialsId,
},
})
credentials = await getCredentials(block.options.credentialsId)
if (!credentials) {
console.error('Could not find credentials in database')
return {
@ -57,15 +55,13 @@ export const executeForgedBlock = async (
const typebot = state.typebotsQueue[0].typebot
if (
action?.run?.stream &&
isPlaneteScale() &&
credentials &&
isCredentialsV2(credentials) &&
state.isStreamEnabled &&
!state.whatsApp &&
isNextBubbleTextWithStreamingVar(typebot)(
block.id,
action.run.stream.getStreamVariableId(block.options)
)
) &&
state.isStreamEnabled &&
!state.whatsApp &&
!process.env.VERCEL_ENV
) {
return {
outgoingEdgeId: block.outgoingEdgeId,

View File

@ -25,9 +25,10 @@
"google-auth-library": "8.9.0",
"google-spreadsheet": "4.1.1",
"got": "12.6.0",
"ky": "^1.1.3",
"libphonenumber-js": "1.10.37",
"node-html-parser": "6.1.5",
"nodemailer": "6.9.3",
"nodemailer": "6.9.8",
"openai": "4.28.4",
"qs": "6.11.2",
"stripe": "12.13.0",
@ -36,7 +37,7 @@
"devDependencies": {
"@typebot.io/forge": "workspace:*",
"@typebot.io/forge-repository": "workspace:*",
"@types/nodemailer": "6.4.8",
"@types/nodemailer": "6.4.14",
"@types/qs": "6.9.7"
}
}

View File

@ -0,0 +1,6 @@
import prisma from '@typebot.io/lib/prisma'
export const getCredentials = async (credentialsId: string) =>
prisma.credentials.findUnique({
where: { id: credentialsId },
})

View File

@ -10,7 +10,7 @@ import {
import { convertMessageToWhatsAppMessage } from './convertMessageToWhatsAppMessage'
import { sendWhatsAppMessage } from './sendWhatsAppMessage'
import * as Sentry from '@sentry/nextjs'
import { HTTPError } from 'got'
import { HTTPError } from 'ky'
import { convertInputToWhatsAppMessages } from './convertInputToWhatsAppMessage'
import { isNotDefined } from '@typebot.io/lib/utils'
import { computeTypingDuration } from '../computeTypingDuration'
@ -141,7 +141,7 @@ export const sendChatReplyToWhatsApp = async ({
Sentry.captureException(err, { extra: { message } })
console.log('Failed to send message:', JSON.stringify(message, null, 2))
if (err instanceof HTTPError)
console.log('HTTPError', err.response.statusCode, err.response.body)
console.log('HTTPError', err.response.status, err.response.body)
}
}
@ -172,7 +172,7 @@ export const sendChatReplyToWhatsApp = async ({
Sentry.captureException(err, { extra: { message } })
console.log('Failed to send message:', JSON.stringify(message, null, 2))
if (err instanceof HTTPError)
console.log('HTTPError', err.response.statusCode, err.response.body)
console.log('HTTPError', err.response.status, err.response.body)
}
}
}
@ -253,7 +253,7 @@ const executeClientSideAction =
Sentry.captureException(err, { extra: { message } })
console.log('Failed to send message:', JSON.stringify(message, null, 2))
if (err instanceof HTTPError)
console.log('HTTPError', err.response.statusCode, err.response.body)
console.log('HTTPError', err.response.status, err.response.body)
}
}
}

View File

@ -1,4 +1,4 @@
import got from 'got'
import ky from 'ky'
import {
WhatsAppCredentials,
WhatsAppSendingMessage,
@ -16,14 +16,16 @@ export const sendWhatsAppMessage = async ({
message,
credentials,
}: Props) =>
got.post({
url: `${env.WHATSAPP_CLOUD_API_URL}/v17.0/${credentials.phoneNumberId}/messages`,
headers: {
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
},
json: {
messaging_product: 'whatsapp',
to,
...message,
},
})
ky.post(
`${env.WHATSAPP_CLOUD_API_URL}/v17.0/${credentials.phoneNumberId}/messages`,
{
headers: {
Authorization: `Bearer ${credentials.systemUserAccessToken}`,
},
json: {
messaging_product: 'whatsapp',
to,
...message,
},
}
)