@botpress/webchat-client
Version:
408 lines (362 loc) • 13.7 kB
text/typescript
import type * as ClientTypes from '../gen/client'
import * as sdk from '../gen/client/sdk.gen'
import { client as baseClient } from '../gen/client/client.gen'
import { EventSource } from 'eventsource'
import type { Signal } from './types/signals'
import { throwErrorInterceptor } from './interceptor'
import { createEventEmitter, EventEmitter } from './emitter'
interface EventSourceLike {
close(): void
addEventListener(event: string, handler: (event: any) => void): void
}
type ConversationIdParam = {
conversationId: string
}
type NextTokenParam = {
nextToken?: string
}
type UserIdParam = {
userId: string
}
type MessageIdParam = {
messageId: string
}
type EventIdParam = {
eventId: string
}
const extractData = <T>(res: { data: T }): T => res.data
type Event =
| Signal
| {
type: 'error'
data: any
}
| {
type: 'open'
data: any
}
type EventMap = {
[E in Event as E['type']]: E['data']
}
const MAX_RETRIES = 10
const RETRY_INTERVAL = 3000 // ms
const TIMEOUT = '60000' // ms
type SubscribeFn<T extends Record<string, unknown>> = EventEmitter<T>['on']
export type Subscribable<T extends Record<string, unknown>> = SubscribeFn<T> & {
on: SubscribeFn<T>
disconnect: () => void
cleanup: () => void
}
const createSubscribable = <T extends Record<string, unknown>>(
eventEmitter: EventEmitter<T>,
disconnect: () => void
): Subscribable<T> => {
const fn = (...args: Parameters<SubscribeFn<T>>) => eventEmitter.on(...args)
return new Proxy(fn, {
get: (_target, prop) => {
if (prop === 'on') return eventEmitter.on
if (prop === 'disconnect') return disconnect
if (prop === 'cleanup') return eventEmitter.cleanup
return Reflect.get(_target, prop)
},
}) as Subscribable<T>
}
export type Client = {
createConversation: () => Promise<ClientTypes.CreateConversationResponse>
getConversation: (params: ConversationIdParam) => Promise<ClientTypes.GetConversationResponse>
deleteConversation: (params: ConversationIdParam) => Promise<ClientTypes.DeleteConversationResponse>
listConversations: (params: NextTokenParam) => Promise<ClientTypes.ListConversationsResponse>
listConversationMessages: (
params: ConversationIdParam & NextTokenParam
) => Promise<ClientTypes.ListConversationMessagesResponse>
addParticipant: (params: ConversationIdParam & UserIdParam) => Promise<ClientTypes.AddParticipantResponse>
removeParticipant: (params: ConversationIdParam & UserIdParam) => Promise<ClientTypes.RemoveParticipantResponse>
getParticipant: (params: ConversationIdParam & UserIdParam) => Promise<ClientTypes.GetParticipantResponse>
listParticipants: (params: ConversationIdParam & NextTokenParam) => Promise<ClientTypes.ListParticipantsResponse>
createMessage: (params: ClientTypes.CreateMessageBody) => Promise<ClientTypes.CreateMessageResponse>
updateMessage: (params: MessageIdParam & ClientTypes.UpdateMessageBody) => Promise<ClientTypes.UpdateMessageResponse>
getMessage: (params: MessageIdParam) => Promise<ClientTypes.GetMessageResponse>
deleteMessage: (params: MessageIdParam) => Promise<ClientTypes.DeleteMessageResponse>
createFile: (params: ClientTypes.CreateFileBody) => Promise<ClientTypes.CreateFileResponse>
getUser: () => Promise<ClientTypes.GetUserResponse>
updateUser: (params: ClientTypes.UpdateUserBody) => Promise<ClientTypes.UpdateUserResponse>
deleteUser: (params: UserIdParam) => Promise<ClientTypes.DeleteUserResponse>
createEvent: (params: ClientTypes.CreateEventBody) => Promise<ClientTypes.CreateEventResponse>
getEvent: (params: EventIdParam) => Promise<ClientTypes.GetEventResponse>
listenConversation: (params: ConversationIdParam) => Subscribable<EventMap>
addMessageFeedback: (
params: MessageIdParam & ClientTypes.AddMessageFeedbackBody
) => Promise<ClientTypes.AddMessageFeedbackResponse>
removeMessageFeedback: (params: MessageIdParam) => Promise<ClientTypes.RemoveMessageFeedbackResponse>
generateUserKey: (
params: ClientTypes.GenerateUserKeyBody & { adminSecret: string }
) => Promise<ClientTypes.GenerateUserKeyResponse>
}
const baseConfigs = {
throwOnError: true,
} as const satisfies sdk.Options
export const createUser = async ({
clientId,
apiUrl,
adminSecret,
customHeaders,
...body
}: {
clientId: string
apiUrl?: string
adminSecret?: string
customHeaders?: Record<string, string>
} & ClientTypes.CreateUserBody) => {
const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}`
// @ts-expect-error - this is a private property
if (baseClient.interceptors.response._fns.length === 0) {
baseClient.interceptors.response.use(throwErrorInterceptor)
}
const configs = {
...baseConfigs,
baseUrl,
}
const headers = {
...(adminSecret ? { 'x-admin-secret': adminSecret } : {}),
...customHeaders,
}
return sdk
.createUser({
...configs,
headers: Object.keys(headers).length > 0 ? headers : undefined,
body,
})
.then(extractData)
}
export const initialize = ({
conversationId,
userToken,
clientId,
apiUrl,
adminSecret,
}: {
conversationId?: string
userToken?: string
apiUrl?: string
clientId: string
adminSecret?: string
}) => {
let retryCount = 0
let retryTimeout: ReturnType<typeof setTimeout> | null = null
let eventSource: EventSourceLike | null = null
const eventEmitter = createEventEmitter<EventMap>()
let resolvedConversationId = conversationId
let resolvedUserToken = userToken
const disconnect = () => {
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const connect = () => {
if (eventSource) {
eventSource.close()
}
const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}`
const url = `${baseUrl}/initialize${resolvedConversationId ? `?conversationId=${resolvedConversationId}` : ''}`
const headers = {
...(adminSecret ? { 'x-admin-secret': adminSecret } : {}),
...(resolvedUserToken ? { 'x-user-key': resolvedUserToken } : {}),
}
eventSource = createNewEventSource(url, headers)
eventSource.addEventListener('open', () => eventEmitter.emit('open'))
eventSource.addEventListener('error', (err) => {
if (retryCount < MAX_RETRIES) {
retryCount++
retryTimeout = setTimeout(() => connect(), RETRY_INTERVAL)
} else {
eventEmitter.emit('error', err)
}
})
eventSource.addEventListener('message', (ev) => {
const event = safeJsonParse(ev.data) as Signal
// Save the Conversation ID and User Token from the init event so that internal
// retries reconnect as the same user to the same conversation.
if (event.type === 'init') {
resolvedConversationId = event.data.conversation.id ?? resolvedConversationId
resolvedUserToken = event.data.user.userKey ?? resolvedUserToken
}
eventEmitter.emit(event.type, event.data)
})
}
connect()
return createSubscribable(eventEmitter, disconnect)
}
export const generateUserKey = async ({
adminSecret,
expiresAt,
userId,
apiUrl,
clientId,
}: {
adminSecret: string
expiresAt: number
userId: string
apiUrl?: string
clientId: string
}) => {
const headers = { 'x-admin-secret': adminSecret }
const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}`
const configs = {
...baseConfigs,
baseUrl,
}
return sdk.generateUserKey({ ...configs, headers, body: { id: userId, expiresAt } }).then(extractData)
}
export const sendPerfMetrics = async (
clientId: string,
body: ClientTypes.SendPerfMetricsBody,
apiUrl?: string,
customHeaders?: Record<string, string>
) => {
const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}`
const configs = {
...baseConfigs,
baseUrl,
}
return sdk.sendPerfMetrics({ ...configs, body, headers: customHeaders }).then(extractData)
}
export const createClient = ({
userKey,
clientId,
apiUrl,
customHeaders,
advancedOptions,
}: {
userKey: string
clientId: string
apiUrl?: string
customHeaders?: Record<string, string>
advancedOptions?: {
eventSource?: (params: { url: string; headers: Record<string, string> }) => EventSourceLike
}
}): Client => {
const headers = {
'x-user-key': userKey,
...customHeaders,
}
const baseUrl = `${apiUrl || baseClient.getConfig().baseUrl}/${clientId}`
const configs = {
...baseConfigs,
baseUrl,
}
// @ts-expect-error - this is a private property
if (baseClient.interceptors.response._fns.length === 0) {
baseClient.interceptors.response.use(throwErrorInterceptor)
}
const client: Client = {
createConversation: () => sdk.createConversation({ ...configs, headers, body: {} }).then(extractData),
getConversation: ({ conversationId }) =>
sdk.getConversation({ ...configs, headers, path: { id: conversationId } }).then(extractData),
deleteConversation: ({ conversationId }) =>
sdk.deleteConversation({ ...configs, headers, path: { id: conversationId } }).then(extractData),
listConversations: ({ nextToken }) =>
sdk.listConversations({ ...configs, headers, query: { nextToken } }).then(extractData),
listConversationMessages: ({ conversationId, nextToken }) =>
sdk
.listConversationMessages({
...configs,
headers,
path: { id: conversationId },
query: { nextToken },
})
.then(extractData),
addParticipant: ({ conversationId, userId }) =>
sdk.addParticipant({ ...configs, headers, path: { id: conversationId }, body: { userId } }).then(extractData),
removeParticipant: ({ conversationId, userId }) =>
sdk.removeParticipant({ ...configs, headers, path: { id: conversationId, userId } }).then(extractData),
getParticipant: ({ conversationId, userId }) =>
sdk.getParticipant({ ...configs, headers, path: { id: conversationId, userId } }).then(extractData),
listParticipants: ({ conversationId, nextToken }) =>
sdk
.listParticipants({ ...configs, headers, path: { id: conversationId }, query: { nextToken } })
.then(extractData),
createMessage: (body) => sdk.createMessage({ ...configs, headers, body }).then(extractData),
updateMessage: ({ messageId, ...body }) =>
sdk.updateMessage({ ...configs, headers, path: { id: messageId }, body }).then(extractData),
getMessage: ({ messageId }) => sdk.getMessage({ ...configs, headers, path: { id: messageId } }).then(extractData),
deleteMessage: ({ messageId }) =>
sdk.deleteMessage({ ...configs, headers, path: { id: messageId } }).then(extractData),
createFile: (body) => sdk.createFile({ ...configs, headers, body }).then(extractData),
getUser: () => sdk.getUser({ ...configs, headers }).then(extractData),
updateUser: (body) => sdk.updateUser({ ...configs, headers, body }).then(extractData),
deleteUser: () => sdk.deleteUser({ ...configs, headers }).then(extractData),
createEvent: (body) => sdk.createEvent({ ...configs, headers, body }).then(extractData),
getEvent: ({ eventId }) => sdk.getEvent({ ...configs, headers, path: { id: eventId } }).then(extractData),
addMessageFeedback: ({ messageId, ...body }) =>
sdk.addMessageFeedback({ ...configs, headers, path: { id: messageId }, body }).then(extractData),
removeMessageFeedback: ({ messageId }) =>
sdk.removeMessageFeedback({ ...configs, headers, path: { id: messageId } }).then(extractData),
generateUserKey: ({ adminSecret, ...body }) =>
sdk.generateUserKey({ ...configs, headers: { 'x-admin-secret': adminSecret }, body }).then(extractData),
listenConversation: ({ conversationId }) => {
let retryCount = 0
let retryTimeout: ReturnType<typeof setTimeout> | null = null
let eventSource: EventSourceLike | null = null
const eventEmitter = createEventEmitter<EventMap>()
const disconnect = () => {
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const connect = () => {
if (eventSource) {
eventSource.close()
}
const url = `${baseUrl}/conversations/${conversationId}/listen`
const providedEventSource = advancedOptions?.eventSource
eventSource = providedEventSource ? providedEventSource({ url, headers }) : createNewEventSource(url, headers)
eventSource.addEventListener('open', () => eventEmitter.emit('open'))
eventSource.addEventListener('error', (err) => {
if (retryCount < MAX_RETRIES) {
retryCount++
retryTimeout = setTimeout(() => connect(), RETRY_INTERVAL)
} else {
eventEmitter.emit('error', err)
}
})
eventSource.addEventListener('message', (ev) => {
const event = safeJsonParse(ev.data) as Signal
eventEmitter.emit(event.type, event.data)
})
}
connect()
return createSubscribable(eventEmitter, disconnect)
},
}
return client
}
const safeJsonParse = (data: any) => {
try {
return JSON.parse(data)
} catch {
return data
}
}
const createNewEventSource = (url: string, headers: Record<string, string>): EventSourceLike => {
return new EventSource(url, {
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
...headers,
timeout: TIMEOUT,
},
}),
})
}